Twitch games

A game played by the people watching it has two ways to hear from them, and they are good at different things.

Companion pageTwitch chat
What a viewer doesOpens a linkTypes in the chat they are already in
What you learn about themA room client id, live for as long as their tab isA Twitch user id
What they can sendAnything your page draws: buttons, a stick, a drawingA line of text
Who they are to the roomAn audience peer, counted in audienceCountNobody. They never connect to irtio
Rate limitingThe platform’s per-viewer budgetYours, in the bridge
ReachWhoever follows the linkEveryone watching, with no step in between

Most streamed games want both. Chat is where the audience already is and costs a viewer nothing; the companion page is where anything more than a word of input has to happen.

The companion page

This is audience rooms, and it is the path that involves the platform. A viewer joins the relay room as an audience peer, sends input in, and receives almost nothing back:

// viewer.ts
import { joinRelay } from '@irtio/client';

const room = await joinRelay({ audience: true }); // takes the room code from ?room= in the URL
room.message({ role: 'host' }, encodeVote('left'));

The streamer’s page reads room.audienceCount for the size of the crowd. Everything else about seats, caps, credentials and the fan-in rules is in that guide.

The chat bridge

@irtio/twitch turns chat commands into calls in the streamer’s own page. There is no service to deploy and no bot account to register: the streamer holds a token, the token reads the streamer’s chat, and the viewers authenticate with nobody.

npm install @irtio/twitch
// host.ts
import { connectTwitchChat, voteWindow } from '@irtio/twitch';

const votes = voteWindow({
  durationMs: 10_000,
  options: ['left', 'right'] as const,
  repeat: true,
  onClose: (result) => steer(result.winner), // null on a tie or an empty window
});

const chat = await connectTwitchChat({
  token,                     // a user access token with user:read:chat
  clientId,                  // the client id that token was issued to
  broadcasterUserId,         // the channel to read, as a numeric id
  commands: {
    '!vote <choice>': ({ viewer, args }) => votes.cast(`twitch:${viewer.id}`, args[0] ?? ''),
  },
  onState: (state) => setChatIndicator(state),
});

A command pattern is the word and its parameters. A message missing a parameter is not a match, so a bare !vote never reaches a handler that asked for a choice, and a trailing <rest...> takes everything after it as one argument. The call resolves once chat is actually flowing, which is after Twitch’s welcome frame and a successful subscription, so an await that returned means your handlers are live.

Underneath it is EventSub over a WebSocket. The bridge handles the session lifecycle: it subscribes to channel.chat.message when the session id arrives, treats a missed keepalive as a dead socket, follows a session_reconnect to the URL Twitch names without re-subscribing, and reconnects with a widening backoff after an unexpected drop. It gives up rather than retrying forever: after maxRetries attempts the state becomes failed, onError gets a message saying why, and nothing further happens. A revoked subscription and a rejected token are fatal immediately, because the same token will get the same answer.

The token

You need three values, and getting them is your job rather than the library’s:

  1. A user access token for the account whose chat is being read, carrying the user:read:chat scope. Twitch’s authentication docs cover the flows; the implicit grant is the usual choice for a page with no backend.
  2. The client id that token was issued to. Twitch refuses the pair when they disagree.
  3. The broadcaster user id, which is numeric and not the login name. GET /helix/users turns a login into one.

By default the token is assumed to belong to the broadcaster, which is the ordinary case of a streamer reading their own chat. Pass userId when the token belongs to a bot account instead.

Keep the token in the streamer’s page and out of your repository. It is theirs, it expires, and nothing in irtio ever sees it.

Vote windows, and why the design is not optional

A viewer watching a broadcast is two to four seconds behind the game. Add the time it takes to read a prompt and type, and the input you receive answers a question that was on screen five seconds ago. No amount of protocol work fixes this: the delay is the stream.

So the unit of crowd input is a window, not a reaction. Open a question, leave it open long enough that a late answer is still an answer, show a countdown, and resolve when it closes. Ten seconds is a good default. Below about six the audience is mostly voting on the previous question.

voteWindow is that pattern with the fiddly parts done:

  • One vote per viewer per window. A viewer repeating the same command counts once. By default the last command wins, so a viewer may change their mind and the count moves with them; set allowChange: false to hold them to their first.
  • A tie is reported as a tie. winner is null when nothing leads, and leaders names everything level at the top. An empty window has no winner either. Deciding what a tie means is the game’s business.
  • Rounds. repeat: true opens the next window the moment one closes, and the dedupe resets with it. restart() opens a new one on your schedule instead.

Both input paths feed the same window. Key a companion voter by their room client id and a chat voter by their Twitch user id with a prefix, so the two can never collide:

room.onMessage((from, bytes) => votes.cast(from, decodeVote(bytes)));       // companion
// and in the command handler above: votes.cast(`twitch:${viewer.id}`, ...)  // chat

A viewer who does both is counted twice. That is worth deciding on purpose rather than discovering on stream.

What this does not do yet

  • A chat voter has no verified irtio identity. The bridge gives you a Twitch user id and a display name. It cannot hand that viewer an audience seat, and nothing links their chat votes to a companion page they might also have open. Anything that has to know “this is the same person” has to go through the companion page.
  • Chat voters never touch irtio. They cost your project nothing, and they also cannot receive anything. A prompt reaches them only by being on the stream.
  • The token is the developer’s problem. There is no hosted OAuth flow here for v1.
  • Twitch Extensions are not supported. Extensions need manual review and a backend that mediates networking, which is a different shape of product.
  • Chat volume is Twitch’s limit, not ours. A very fast chat is a lot of EventSub frames arriving in one page. The dedupe keeps the tally cheap, but the frames still arrive.

A complete example

examples/crowd-vote is the whole lane in one small game: an audience room, a repeating ten-second window, a companion page, and the chat bridge behind an optional form. Its README walks both input paths.