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 page | Twitch chat | |
|---|---|---|
| What a viewer does | Opens a link | Types in the chat they are already in |
| What you learn about them | A room client id, live for as long as their tab is | A Twitch user id |
| What they can send | Anything your page draws: buttons, a stick, a drawing | A line of text |
| Who they are to the room | An audience peer, counted in audienceCount | Nobody. They never connect to irtio |
| Rate limiting | The platform’s per-viewer budget | Yours, in the bridge |
| Reach | Whoever follows the link | Everyone 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:
- A user access token for the account whose chat is being read, carrying the
user:read:chatscope. Twitch’s authentication docs cover the flows; the implicit grant is the usual choice for a page with no backend. - The client id that token was issued to. Twitch refuses the pair when they disagree.
- The broadcaster user id, which is numeric and not the login name.
GET /helix/usersturns 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: falseto hold them to their first. - A tie is reported as a tie.
winnerisnullwhen nothing leads, andleadersnames everything level at the top. An empty window has no winner either. Deciding what a tie means is the game’s business. - Rounds.
repeat: trueopens 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.