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, 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: 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.
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.