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

Choose a vote window long enough for viewers to see the prompt and respond through the broadcast delay. Start with ten seconds, show a countdown, and adjust after testing your stream.

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.

Chat voters receive prompts through the stream and do not connect to irtio. Their Twitch ids are not linked to companion-page identities. If your game requires one vote per person across both paths, verify and link identities in your application.

Test with the chat volume you expect. Vote deduplication reduces tally work but does not reduce incoming Twitch frames processed by the streamer’s page.

A complete example

examples/crowd-vote combines 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.