← All docs Games for Group Β· Architecture

The Live Channel

One page to understand the live channel and use it in a new feature. What it is, how a phone gets in, what it costs, and two worked examples.

⚑ The live channel is a side path next to the game. Convex still owns every real move. A tiny Cloudflare relay passes the "fine to lose" things between the phones in a room, like cursors in Figma, and stores no messages.

The one rule: if losing a message would be wrong, it cannot go on the relay.

The picture

Quick Draw, online. The same drawing travels two ways at once.

Fast path Β· the relay Β· the line while it is being drawn
Drawer's phonepen points, gathered
~10 msgs/s
Relay roomone per online room Β· stores no messages
stamped "from"
Guessers' phonesdraw the growing line
Sure path Β· Convex Β· the finished line
Drawer's phonepen lifts: one stroke action
a game move
Convexchecks the rules Β· saves it
room state
Everyonethe real stroke replaces the live one

The two paths side by side

The relay (live channel)Convex (the game)
CarriesPreviews: a line being drawn, later a pointerMoves: a finished stroke, a guess, a score
SavesNothing. A late joiner hears nothing from beforeEverything, and replays it to anyone who arrives
Checks rulesNo. It only checks who sent it, size and speedYes. The game's rules run on every action
If it is downNobody notices. Drawings appear a stroke at a timeThe online game stops
Cost driverSeconds a room is awake, plus message countFunction calls, stored data, subscriptions

Getting in: the room pass (ticket)

The relay never calls Convex. Convex hands the phone a signed pass, and the relay only checks the signature.

  1. The phone asks Convex for a pass, with its room id and its device's session id.
  2. Convex finds the player for that session in that room. No player row, no pass (the phone plays on without the channel).
  3. Convex signs { room, player, signed at, expires in 2 minutes } with LIVE_SECRET. Nothing is written to the database.
  4. The phone connects to wss://live.gamesforgroup.com/room/<roomId>?ticket=…
  5. The relay checks the signature, the room and the time. A bad pass is turned away before the room even wakes.
  6. The relay stamps from on every message that socket sends, using the player in the pass. A phone can never claim to be someone else.
  7. The pass is read once, at connect. An open socket keeps working until it closes. Every new connect asks Convex for a fresh pass, so a player who is not in the room gets none. A player removed while connected is cut off at once by the kick below, and the passes they already hold stop working.
PHONE CONVEX RELAY ROOM OTHER PHONES 1 Β· pass please (room, session) 2 Β· find this player in the room 3 Β· sign {room, player, +2 min} ticket 4 Β· open socket /room/<id>?ticket=… 5 Β· signature, room, time ok? connected { topic: "pen", data } 6 Β· + from, at
Why nothing is stored

A pass is worked out when asked and simply stops working after 2 minutes. No table, no clean-up job. It only has to last from Convex to the relay's front door.

Why it rides in the address

A phone's WebSocket cannot reliably send custom headers. So the pass goes in the link, which is fine for something that dies in 2 minutes.

One signing file

Convex and the relay import the same file to sign and to check. The two sides cannot drift apart.

How a removed player is cut off: the kick

The pass is checked only at connect, so removal has its own path. A removed player stops hearing the room at once, whatever feature holds the channel.

  1. A player row is deleted. The host's Remove (lobby or mid-match), a guest's own Leave, and the host closing the room all go through one place, which also deletes presence.
  2. That same place schedules one kick, which runs only if the removal is saved. One call per removed player. No table, no shared document.
  3. Convex calls the relay: POST /room/<roomId>/kick with { playerId } and a signed kick pass (1 minute, bound to that room and player). A join pass is never accepted as a kick, and a kick pass never opens a socket.
  4. The relay closes every socket that player holds in the room, with code 4003. The phone stays closed until its screen lets go of the channel and holds it again.
  5. The room remembers the kick for 2 minutes, the life of a pass. A connect from that player with a pass signed at or before the kick is refused, so a pass fetched before the removal cannot reopen a socket after it. A player let back in gets a pass signed later, and connects as normal. After 2 minutes every such pass has expired anyway, so the room forgets the kick.
IfThen
The kick call fails, or the relay is downNothing errors. The removed phone keeps any open socket until it closes, and Convex gives it no new pass. A pass it already holds still opens a socket until that pass expires, within 2 minutes
The backend has no LIVE_HTTP_URL or LIVE_SECRETNo kick is scheduled at all. The dev backend runs in the cloud and cannot reach a relay on a laptop, so it leaves the address unset
The stale-room clean-up deletes a room's playersNo kick. Those rooms have been silent for far longer than the relay's 3-minute idle close
Worth knowing Checking the pass only at connect is the price of never asking Convex on every message. The kick and the room's memory of it close that gap for removal. Everyone still in the room hears everything, so nothing secret goes here.

Inside a relay room

One Cloudflare "Durable Object" per online room: a tiny always-addressable room object, named by the Convex room id. It knows nothing about games. A topic is just a name.

A phone sends
{ topic: "pen", data: {…} }

Goes to every other player's sockets in the room. The sender does not hear its own message.

The others receive
{ topic: "pen", data: {…}, from: "playerId", at: 1757… }

from comes from the pass, at from the relay's clock. Trust both. Never trust data: check its shape.

LimitValueWhat happens past it
Message size8 KBDropped, silently
Sockets in one room40The next one is turned away
Speed, per socket (one connection)40 messages a secondThe extra ones are dropped, silently
Flood, per socketMore than 80 messages in one secondThe socket is closed (code 1008). An honest phone never comes near it: the pen sends about 10 a second
Sockets per player2 (two tabs, or a reconnect while the old socket is still closing)A third closes that player's oldest socket (code 4001). Neither socket hears that player's own messages
Topic name64 charactersDropped
Quiet room3 minutes with no message passed onThe room closes its sockets (code 4000). A phone still using it reconnects
Close codeMeansThe phone
4000The room went quietReconnects if a screen still holds the channel
4001Replaced by a newer socket of the same playerStays closed until its screen lets go and holds the channel again, so two tabs never take turns knocking each other off
4003Removed from the room (the kick)Stays closed until its screen lets go and holds the channel again
1008Flood: far too many messagesRetries, and its waits keep growing
What counts as activity A room has a single wake-up timer for the quiet-room check. A message passed on counts as activity, and so does the connect that opens an empty room. A dropped message does not, so a flood never keeps a room open. Nor does a phone joining a room that is already open, so a phone retrying its connection never keeps a silent room open.
An empty room keeps only its recent kicks When a room's last socket closes, the room deletes everything it stored: the quiet mark, the wake-up timer and every kick older than 2 minutes. A kick younger than that stays, with one timer set for when it stops mattering. When that timer fires on an empty room, the room deletes the rest and sets no other.
Sleeping, in plain words A room with open sockets but no messages hibernates: Cloudflare keeps the connections, unloads the room, and stops billing. The next message wakes it in an instant. Billing is for the time a room is busy, so a steady stream (a pen moving) costs the most. Closing the socket when nobody needs it keeps a room at zero. The numbers are in What features cost.

On the phone: the kit

A board gets the channel as an optional live prop and uses one hook. It never knows which mode it is in.

import { useLiveChannel } from '~/live'

// Open only while this is true. Returns { publish, subscribe }.
const pointer = useLiveChannel<{ x: number; y: number }>(live, 'pointer', state.phase === 'playing')

// Send (to everyone else; dropped if not connected)
pointer.publish({ x: 120, y: 480 })

// Hear
useEffect(() => pointer.subscribe((msg) => {
  // msg.from = who sent it (trust it). msg.data = anything (check it).
}), [pointer.subscribe])
BehaviourWhat it means for you
Open only while heldThe socket opens when the first screen passes active = true and closes when the last one lets go
2 second lingerClosing waits 2 s, so a quick hand-over between turns does not reconnect
Background closes itThe app going to the background closes the socket. Coming back reopens it if a screen still holds it
One socket per roomEvery topic on screen shares it. Two hooks do not mean two connections
A fresh pass each connectEvery connect and reconnect asks Convex for a new pass. An open socket never asks again
Says when it opensonOpen on the connection tells a listener every time a socket opens, a reconnect included (and at once if it is already open). The lobby uses it to ask what it missed
Silent retryNo pass, relay down, over the free allowance, no network: its first wait is 0.5 to 1 s, then it doubles, up to 60 s, always with a little randomness. The waits start over only once a socket has stayed open 10 s: every connect asks Convex for a pass, so a relay that lets a socket in and drops it is waited out like one that is down, never retried once a second by a whole room. After a kick (4003) or being replaced by a newer socket of the same player (4001) it does not retry. No error ever reaches the player
ModeHas a channel?Why
Onlineβœ“The online screen makes one relay connection for the room
Same deviceβ€”One screen. Nobody else to show. live is left out
LAN (paused)β€”No internet by definition. The host phone's own socket could carry the same topics later

Without a channel, publish does nothing and subscribe hears nothing. So the board has no mode checks, and must play fully without it.

Worked example: Quick Draw's live pen

  1. When it opens. Only during a drawing turn in a room of phones. The one-phone easel version has nobody to show. Between turns it closes.
  2. The drawer sends in batches. Pen points are rounded, tiny moves are skipped, and what gathered goes out every 100 ms (about 10 messages a second, not 60). Lifting the pen sends the rest at once, marked "line ended". While the pen is down but held still, an empty "still drawing" message goes out about once a second.
  3. Guessers ignore everyone but the drawer. A message whose from is not the current drawer is thrown away, and every batch's shape is checked.
  4. Smooth, not jumpy. Each batch is revealed a little per frame across the next 100 ms, so the line grows instead of jumping.
  5. Handover with no flicker. Each live line knows the place its real stroke will take. The same screen update that first paints the real stroke stops painting the live line. Never both, never neither.
  6. Clean-up. A new turn starts empty. Clear or Undo drops every live line. A finished line whose real stroke never arrives is let go after 4 seconds. An unfinished line that hears nothing from the drawer for 3 seconds, neither points nor "still drawing", is let go too: the drawer has dropped.
  7. Late joiners. They get every finished stroke from Convex. Of the line in progress they see only what is drawn after they arrived, until the real stroke lands.

The rules are plain functions with tests (quick-draw/ui/livePen.ts). The hook that wires them to the screen is quick-draw/ui/useLivePen.ts.

Worked example: who is where in the lobby

The online lobby is a room people walk about in. Where each of them stands is agreed between the phones on this channel and stored nowhere: a walk across the room is not a database write, and wakes nobody's subscription.

TopicSaysCarriesSize sent (30 in the room)
room.goMy moveThe scene, the place and its spot, my own count of my moves, and, when I came down a ride, which of the scene's rides it was (its number in the scene's own order, from one to eight). Never who: the relay's stamp is the only "who" there is, so nobody can move anybody else75 bytes
room.doSomething short-lived I didA hello or a bounce where I already am, or a toy used. A toy that keeps its state (the lamp) says how far it now is; one that does not (the popcorn) is seen once60 to 75 bytes
room.helloWhat did I miss?The scene. Sent every time this phone's socket opens50 bytes
room.whereThe room as I hold itEverybody's latest move as a short row each, the old move of whoever asked, and how each lasting toy was leftAbout 1.6 KB (0.5 KB for a room of 8). The relay passes 8 KB
  1. Held while the room is looked at. The lobby holds the socket while its screen is in front and the app is in the foreground. A game starting, or another screen opened over it, lets it go.
  2. A phone asks, and one phone answers. On every open a phone says hello and says its own place again. Every phone that knows the room waits its turn (the host first, then the order people came in, a quarter of a second apart, skipping whoever asked and whoever's phone is away) and stands down when it hears somebody else's snapshot. One answer, not twenty-nine.
  3. The first move waits to be told. A phone makes no move of its own until a snapshot has come, or 1.2 seconds have passed since its socket opened. If the snapshot holds a move of its own it makes none: someone who reloads is back where they were. Otherwise what the move is depends on one thing. Somebody who has only just come into the room makes their way in, through the scene's door to a place that is free (down the slide at the pool): the one entrance there is, drawn by every phone, once. Anybody else was here already and only keeps the place the room had drawn them in. That is what happens when everybody opens the room at once (a game has just ended): thirty phones keeping thirty different spots never collide, and nobody is seen coming in who never left.
  4. Another scene is another room to be told about. The host's pick of a scene is not on this channel: it is on the room, in the database, and every phone hears it from there. Each phone then starts its talk again for the new scene on the socket it already holds: it asks what it missed there, and from then on ignores whatever is said about the old scene (as a phone still in the old one ignores what is said about the new). Nothing has to be said for the change itself: everybody is simply there in the new scene, where it welcomes them, and that is read off the roster every phone already holds. Each phone's first move there is the same as after a game: it keeps the place it stands in.
  5. A few moves a second, at the most. A storm of taps leaves the phone as its last one. A move made while the socket is down still moves that person on their own screen, and is said again when the socket opens.
  6. A phone that knows more says so. When a snapshot is behind what a phone holds (a later move, or a move it does not carry, for someone in the room or for whoever asked), that phone sends a snapshot of its own, in its turn, at most once every twenty seconds. So whichever phone missed a message, the one that answers included, is level again after the next snapshot anybody sends.
  7. A quiet room is told again. When no snapshot has passed for 150 seconds, the phone whose turn it is sends one. It brings phones level that nobody noticed had drifted, and it keeps the relay from closing a silent room after three minutes, which would make every phone reconnect and ask the backend for a pass.
What is promised, and what heals Two phones that have heard the same moves and hold the same roster draw the same room, whatever order those reached them in. Two things are not held, and heal instead: a phone that missed a message draws a different room until the next snapshot, and someone who comes back is where they were once a snapshot reaches their phone (until then their phone draws them where the room welcomes them). Being told is not seeing: when a snapshot puts people somewhere else they are simply there, and nobody is drawn walking, or riding the slide again, to a place they were already in. With no channel at all a phone draws everyone where the scene welcomes them, and its own taps still move it on its own screen.
Read as rubbish until it proves otherwise Everything off the wire is checked for shape, then checked against the scene; anything that does not fit is ignored, never thrown, and a snapshot is read no further than a room could hold. A second wave or bounce from one sender inside a moment is not played, one question is kept for each person however often they ask, and a phone sends a snapshot at most every third of a second. A snapshot does speak of other people (it is how someone who comes back learns where they were), so a tampered phone could put a made-up move in one, and that is all it could do: a snapshot's counter is believed no higher than 10,000 while a person's own move may be numbered far past it, so the friend's next tap puts them where they tapped on every phone. A lasting toy is guarded the same way: how far on the lamp is, is believed no more than 10,000 past what the phone itself holds, in a snapshot and in a tap alike, so a made-up count cannot leave it dead (the next real tap is one more, and is believed). And a ride is named by a number that must be a whole one from one to eight, or the message is no move; a number in that range that the scene has no ride for still moves its person, who is simply there with no ride drawn, so two phones never disagree about where somebody is because of how they got there. It is a friend's mascot in a waiting room, and the host can remove whoever does it.

The messages and whose turn it is are plain functions with tests (modes/online/roomWire.ts); the rules of who is where are the scene's own, tested (ui/scene/places.ts). The hook that wires them to the room is modes/online/useRoomPlaces.ts.

Using it elsewhere

Example: a teammate pointer in Spy Grid

While a team is guessing, each guesser's finger shows as a coloured dot on their teammates' screens, so "this one?" needs no words. This is an idea, not built.

// in Spy Grid's Board
import { createBatcher, useLiveChannel } from '~/live'
import { canReveal } from './game'

type Point = { x: number; y: number }   // 0..1000 across the grid, so every screen size agrees

const guessing = state.phase === 'playing' && state.turn.step === 'guess'
const pointer = useLiveChannel<Point>(live, 'cursor', guessing)

// SEND: only a guesser on the acting team. Keep the latest point, send ~8 a second.
const [batcher] = useState(() => createBatcher<Point>(120, (pts) => pointer.publish(pts[pts.length - 1]!)))
const onMove = (p: Point) => { if (canReveal(state, me)) batcher.push(p) }

// HEAR: one dot per guesser, at their latest point
const [dots, setDots] = useState<Record<string, Point & { at: number }>>({})
useEffect(() => pointer.subscribe((msg) => {
  if (!canReveal(state, msg.from)) return                 // only acting guessers
  const d = msg.data as Partial<Point>
  if (typeof d?.x !== 'number' || typeof d?.y !== 'number') return   // untrusted: check it
  setDots((old) => ({ ...old, [msg.from]: { x: d.x!, y: d.y!, at: msg.at } }))
}), [pointer.subscribe, state, me])

useEffect(() => { if (!guessing) setDots({}) }, [guessing])   // turn over: clear
Why the guess step, not the clue step Every socket in the room hears every message, the other team and spectators too. A spymaster's finger hovering over cards would leak the key. So only guessers publish, and only while guessing.

Reactions kit

Live-stream style emoji reactions: a player taps an emoji and it floats up every screen in the room, like hearts on a live video. Built once in ~/ui, so a game, a lobby or a results screen adds it in three lines. Quick Draw, Charades and Undercover use it. The eight emojis are Google's Noto 3D pictures bundled with the app (ReactionIcon, credits in assets/reactions/), never the phone's emoji font, so every phone shows the same ones. In Quick Draw reactions are for the people watching, never the one drawing. Everyone sees the emojis float up the canvas while a drawing is on and through its reveal. On a phone a watcher taps the drawer's mascot in the canvas's corner and the row opens above it, folding away four seconds after the last tap, and the emojis rise from just above the mascot and fan out up the paper; on a tablet or desktop the row always shows under the canvas, where the drawer's tools sit on the drawer's screen. The row is out wherever the screen has room, and behind a mascot only where it would cramp the screen: Quick Draw's phone has no room (the paper takes the width), Charades' acting screen has, so there the row sits at the foot of every watcher's screen, phone included, and the emojis rise from it over the clock. Undercover's unmasking has the room too: the row sits over the button and nobody's mascot is a button.

Reactions can belong to one match. A room running a tournament plays several matches at once, and a cheer is for the board someone is looking at. The hook takes a third argument, the topic: matchReactionTopic(key) makes one from the match's own name (react:r1m2), so only the screens holding that topic send and hear those emoji, on the room's same one socket. Left out, the reactions are the whole room's, which is what Quick Draw, Charades and Undercover use. Someone watching a tournament has the row out under the screen and the emoji rise across the whole board (side="across"); the two players of a match see none unless they switch them on, and then their screen holds the same topic (MatchReactions in modes/online/tournament/watch). With no live channel in the build the row is not there and everything else works.

import { FloatingReactions, ReactionBar, ReactionRow, useReactions } from '~/ui'

const reactions = useReactions(live, state.phase === 'drawing')   // holds the channel only while true
const cheers = useReactions(live, true, matchReactionTopic(matchKey)) // one match's own, in a room playing several

<FloatingReactions reactions={reactions.reactions} bottom={64} />   // over the area people cheer at (side="left" | "center" | "right")
<FloatingReactions reactions={…} corner={{ left: 6, width: 54 }} />   // or rising from a corner, fanning out (Quick Draw's phone: the mascot)
<ReactionBar reactions={reactions} />                                // a corner button that opens the row; hides with no channel
<ReactionRow reactions={reactions} />                                // or the row itself, always showing

const menu = useReactionMenu(reactions.available)                    // a trigger of your own (Quick Draw's mascot), from '~/ui/reactions/ReactionBar':
<ReactionMenuRow reactions={reactions} menu={menu} align="start" />   // the bar's row, fold-away and pause, opened by menu.toggle
RuleValueWhy
Topicreact, sent as { r: 'fire' }An id, never the emoji, so every phone agrees on what it is
The picturesGoogle's Noto 3D, bundledEach screen shows the id as a picture shipped with the app (128px PNGs, Apache 2.0), so no phone's emoji font decides what the room sees
The setπŸ‘ πŸ‘Ž ❀️ 😍 πŸ˜‚ πŸ”₯ πŸ‘ πŸ€”Tuned for a drawing game: thumbs up and thumbs down, a red heart, love it, so funny, on fire, nice one, and "what is it?" (all eight named by Karim). The heart is sent as the id heart and shown as Noto's red heart everywhere
The rowOne line, never twoThe buttons fit the width the row has: up to 40px in a phone's corner, up to 44px under a big screen's canvas, never under 36px. All eight show at 375px; only a narrower box scrolls sideways, and it comes to rest on whole emojis
SendingAt most 5 a second from one phoneExtra taps are ignored silently. Far under the relay's 40 a second and its 80 a second flood close
Spam pause10 sent inside 10 s pauses that phone for 10 sThe emojis dim and send nothing, no message. A thin bar along the row (and a ring around the round corner button, none around Quick Draw's mascot) fills over the 10 s, then the emojis pop back. Cheering in bursts is fine, holding the room hostage is not
HearingKnown ids only, at most 8 a second from one playerAnything else is thrown away, so one bad phone cannot fill the screen
On screenAt most 30, each floats about 2.4 sA new one drops the oldest
Your own tapFloats at onceThe relay never sends a player's own message back
ModeReactions?Why
Onlineβœ“Over the room's relay, while the screen holds it
Same deviceβ€”No live, so the bar hides. One screen: the table cheers out loud
LAN (paused)β€”No live, so the bar hides
Cost A reaction is a few bytes and wakes the room only while people are tapping. The screen picks active, so the channel is held only for the moment worth cheering. In a room that already holds the channel (Quick Draw's pen) it adds almost nothing. The rules are plain code with tests in ui/reactions/reactionFeed.ts, and the /catalog shows it with a pretend crowd.

Good fit or not?

IdeaFitWhy
"Karim is typing…"βœ“Missing one blip changes nothing
Emoji reactions floating upβœ“Decoration. Lost one, nobody knows. Built: the reactions kit
A pointer or finger positionβœ“The next position replaces the last
A decorative host animation others watchβœ“The real result still comes from state
Buzzer: who pressed firstβœ—Decides a score. Must be a Convex action, with Convex's clock
Chat historyβœ—The relay keeps nothing. Refresh and it is gone
A player's secret answer or handβœ—Everyone in the room can hear the socket
Something that changes once every few secondsβœ—A normal game action is simpler and already works everywhere

Before you put something on the live channel

What features cost

What a live feature costs to run, per room and at scale. Prices from Cloudflare's pricing and limits pages (September 2026). The first-time reasoning is in decisions.

How the bill works

1 Β· Awake time

A room is billed as 128 MB for every second it is active. Features in the same room share one awake bill, so adding reactions to a Quick Draw room adds almost nothing.

2 Β· Incoming messages

20 messages a socket sends = 1 request. Passing a message on to the other phones is free. Each socket opening, and each kick, is also a request.

Free planWorkers Paid ($5 a month)
Awake time13,000 GB-s a day400,000 GB-s a month included, then $12.50 per million GB-s
Requests100,000 a day1 million a month included, then $0.15 per million
Going overCalls fail until midnight UTC. Phones retry quietly, drawings fall back to a stroke at a timeBilled

Per room-hour

10 players, the feature keeps the room busy the whole hour. One awake room-hour is 3,600 s Γ— 0.125 GB = 450 GB-s β‰ˆ $0.0056. Messages add $0.15 per million requests on top, which is why the pen and the mascots cost a little more. The free day holds 13,000 Γ· 450 β‰ˆ 29 awake room-hours.

FeatureWho sends, how oftenMessages an hourPaid, per room-hourFree room-hours a day
Emoji reactions (lobby, results, in game)About 1 reaction every 5 s, from anyone720$0.006~29
Typing statusEach typer, at most 1 every 2 s~9,000$0.006~29
Quick Draw live penThe drawer, 10 a second36,000$0.006~29
Places in the lobby, as builtEach person, a move when they tap (a few a second at the very most), and one snapshot when the room has been quiet 150 s~3,600 for a lively room$0.006~29
Mascots roaming a lobby (Among Us style)All 10 players, 10 a second while moving360,000$0.008~5 (the request limit binds)
Same mascots at 5 a second, smoothed on screenAll 10 players, 5 a second180,000$0.007~11

At scale, paid plan

Usage rates only, before the included amounts and the $5 plan fee.

Room-hours a monthReactions or typingQuick Draw penMascots at 10 a second
1,000β‰ˆ $5.70β‰ˆ $5.90β‰ˆ $8.30
10,000β‰ˆ $57β‰ˆ $59β‰ˆ $83
100,000β‰ˆ $570β‰ˆ $590β‰ˆ $830

Each is 1,000 room-hours Γ— $0.0056 awake, plus that feature's messages: 36,000 an hour for the pen is 1,800 requests, about $0.27 per 1,000 room-hours.

Worked examples

SessionWhat it usesPaidFree
One Quick Draw game, 10 players, 3 rounds30 turns Γ— 82 s awake β‰ˆ 2,460 s β‰ˆ 310 GB-s. β‰ˆ 1,500 room requests, β‰ˆ 300 Worker requestsβ‰ˆ $0.004~42 such games a day (awake time binds)
A 2-hour party night, 10 playersReactions + typing + the Quick Draw pen + a 20-minute mascot lobbyβ‰ˆ 1 centA small slice of one day
Before you build one
Assumptions
Why "open only when needed"

Awake seconds and requests are the bill. A quiet open socket costs nothing, but every message wakes the room. Closing the socket outside the moment that needs it keeps a room at zero.

Where the code lives

PieceWhereWhat it does
Relay front doorlive/src/index.tsCarries out what the gate decides
Relay gatelive/src/gate.tsJoin or kick: checks the pass, refuses plainly when the relay has no secret. Tested in plain Node
Relay roomlive/src/room.tsHolds a room's sockets, sleeps, closes when quiet, closes a kicked or flooding socket, refuses a kicked player's old passes, keeps only recent kicks once empty
Relay ruleslive/src/relay.tsLimits, flood, sockets per player, which sockets a kick closes, which passes a kick refuses, stamping from. Tested in plain Node
The passlive/src/ticket.tsSign and check join and kick passes. Bundled by Convex AND the relay, so a change ships only when both deploy
Pass issuer and kickconvex/live.tsFinds the player and signs the join pass. Runs the scheduled kick
Kick on removalconvex/lib/liveKick.ts, convex/lib/members.tsEvery player removal schedules one kick. Skipped when the backend has no relay settings
Phone connectionapps/app/src/live/connection.tsOne socket per room: hold, linger, retry, pause
The lobby's placesapps/app/src/modes/online/roomWire.ts, useRoomPlaces.tsThe four messages and whose turn it is to answer (tested), and the hook that puts the wire round one phone's memory of the room
Reactions kitapps/app/src/ui/reactions/The feed rules (send limit, checks, cap), the hook, the floating overlay and the bar
Board hookapps/app/src/live/useLiveChannel.tsThe one thing a board calls
Batcherapps/app/src/live/batcher.tsTurns a fast stream into a few messages a second
Relay addressapps/app/src/live/config.tsEXPO_PUBLIC_LIVE_URL, or the local relay in dev
Online wiringapps/app/src/modes/online/useOnlineLive.tsMakes the connection, closes it in the background
The contractpackages/engine typesBoardProps.live, LiveRoom, LiveMessage

How it ships

Its own Cloudflare Worker, g4g-live, at live.gamesforgroup.com. Full steps in releasing (section 2b). In short: the same LIVE_SECRET on the Worker and on Convex production, LIVE_HTTP_URL=https://live.gamesforgroup.com on Convex production (for the kick), EXPO_PUBLIC_LIVE_URL=wss://live.gamesforgroup.com in release builds, Convex deployed first, then:

bun run deploy:live

Running it locally

bun run live:dev
The stale-bundle trap After pulling or writing new live-channel code, reload the simulator and the browser. An app still running the old bundle has no channel at all, and looks exactly like a relay that is not working.

Decision log

Last updated: 2026-10-13 Β· Maintained under the CLAUDE.md rule "Architecture docs, keep them in sync". If the code changed and this page did not, that is a bug. Fix the page.