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.
Quick Draw, online. The same drawing travels two ways at once.
| The relay (live channel) | Convex (the game) | |
|---|---|---|
| Carries | Previews: a line being drawn, later a pointer | Moves: a finished stroke, a guess, a score |
| Saves | Nothing. A late joiner hears nothing from before | Everything, and replays it to anyone who arrives |
| Checks rules | No. It only checks who sent it, size and speed | Yes. The game's rules run on every action |
| If it is down | Nobody notices. Drawings appear a stroke at a time | The online game stops |
| Cost driver | Seconds a room is awake, plus message count | Function calls, stored data, subscriptions |
The relay never calls Convex. Convex hands the phone a signed pass, and the relay only checks the signature.
{ room, player, signed at, expires in 2 minutes } with LIVE_SECRET.
Nothing is written to the database.wss://live.gamesforgroup.com/room/<roomId>?ticket=β¦from on every message that socket sends, using the
player in the pass. A phone can never claim to be someone else.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.
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.
Convex and the relay import the same file to sign and to check. The two sides cannot drift apart.
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.
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.4003. The phone stays closed until its screen lets go of the channel and holds it again.| If | Then |
|---|---|
| The kick call fails, or the relay is down | Nothing 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_SECRET | No 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 players | No kick. Those rooms have been silent for far longer than the relay's 3-minute idle close |
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.
{ topic: "pen", data: {β¦} }
Goes to every other player's sockets in the room. The sender does not hear its own message.
{ 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.
| Limit | Value | What happens past it |
|---|---|---|
| Message size | 8 KB | Dropped, silently |
| Sockets in one room | 40 | The next one is turned away |
| Speed, per socket (one connection) | 40 messages a second | The extra ones are dropped, silently |
| Flood, per socket | More than 80 messages in one second | The socket is closed (code 1008). An honest phone never comes near it: the pen sends about 10 a second |
| Sockets per player | 2 (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 name | 64 characters | Dropped |
| Quiet room | 3 minutes with no message passed on | The room closes its sockets (code 4000). A phone still using it reconnects |
| Close code | Means | The phone |
|---|---|---|
4000 | The room went quiet | Reconnects if a screen still holds the channel |
4001 | Replaced by a newer socket of the same player | Stays closed until its screen lets go and holds the channel again, so two tabs never take turns knocking each other off |
4003 | Removed from the room (the kick) | Stays closed until its screen lets go and holds the channel again |
1008 | Flood: far too many messages | Retries, and its waits keep growing |
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])
| Behaviour | What it means for you |
|---|---|
| Open only while held | The socket opens when the first screen passes active = true and closes when the last one lets go |
| 2 second linger | Closing waits 2 s, so a quick hand-over between turns does not reconnect |
| Background closes it | The app going to the background closes the socket. Coming back reopens it if a screen still holds it |
| One socket per room | Every topic on screen shares it. Two hooks do not mean two connections |
| A fresh pass each connect | Every connect and reconnect asks Convex for a new pass. An open socket never asks again |
| Says when it opens | onOpen 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 retry | No 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 |
| Mode | Has 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.
from is not
the current drawer is thrown away, and every batch's shape is checked.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.
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.
| Topic | Says | Carries | Size sent (30 in the room) |
|---|---|---|---|
room.go | My move | The 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 else | 75 bytes |
room.do | Something short-lived I did | A 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 once | 60 to 75 bytes |
room.hello | What did I miss? | The scene. Sent every time this phone's socket opens | 50 bytes |
room.where | The room as I hold it | Everybody's latest move as a short row each, the old move of whoever asked, and how each lasting toy was left | About 1.6 KB (0.5 KB for a room of 8). The relay passes 8 KB |
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.
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
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
| Rule | Value | Why |
|---|---|---|
| Topic | react, sent as { r: 'fire' } | An id, never the emoji, so every phone agrees on what it is |
| The pictures | Google's Noto 3D, bundled | Each 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 row | One line, never two | The 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 |
| Sending | At most 5 a second from one phone | Extra taps are ignored silently. Far under the relay's 40 a second and its 80 a second flood close |
| Spam pause | 10 sent inside 10 s pauses that phone for 10 s | The 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 |
| Hearing | Known ids only, at most 8 a second from one player | Anything else is thrown away, so one bad phone cannot fill the screen |
| On screen | At most 30, each floats about 2.4 s | A new one drops the oldest |
| Your own tap | Floats at once | The relay never sends a player's own message back |
| Mode | Reactions? | 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 |
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.| Idea | Fit | Why |
|---|---|---|
| "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 |
live missing (same device, relay down)?active true only for the moment it is needed, not the whole match?msg.data, filter by msg.from, and hand over to state
without a flicker?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.
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.
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 plan | Workers Paid ($5 a month) | |
|---|---|---|
| Awake time | 13,000 GB-s a day | 400,000 GB-s a month included, then $12.50 per million GB-s |
| Requests | 100,000 a day | 1 million a month included, then $0.15 per million |
| Going over | Calls fail until midnight UTC. Phones retry quietly, drawings fall back to a stroke at a time | Billed |
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.
| Feature | Who sends, how often | Messages an hour | Paid, per room-hour | Free room-hours a day |
|---|---|---|---|---|
| Emoji reactions (lobby, results, in game) | About 1 reaction every 5 s, from anyone | 720 | $0.006 | ~29 |
| Typing status | Each typer, at most 1 every 2 s | ~9,000 | $0.006 | ~29 |
| Quick Draw live pen | The drawer, 10 a second | 36,000 | $0.006 | ~29 |
| Places in the lobby, as built | Each 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 moving | 360,000 | $0.008 | ~5 (the request limit binds) |
| Same mascots at 5 a second, smoothed on screen | All 10 players, 5 a second | 180,000 | $0.007 | ~11 |
Usage rates only, before the included amounts and the $5 plan fee.
| Room-hours a month | Reactions or typing | Quick Draw pen | Mascots 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.
| Session | What it uses | Paid | Free |
|---|---|---|---|
| One Quick Draw game, 10 players, 3 rounds | 30 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 players | Reactions + typing + the Quick Draw pen + a 20-minute mascot lobby | β 1 cent | A small slice of one day |
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.
| Piece | Where | What it does |
|---|---|---|
| Relay front door | live/src/index.ts | Carries out what the gate decides |
| Relay gate | live/src/gate.ts | Join or kick: checks the pass, refuses plainly when the relay has no secret. Tested in plain Node |
| Relay room | live/src/room.ts | Holds 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 rules | live/src/relay.ts | Limits, flood, sockets per player, which sockets a kick closes, which passes a kick refuses, stamping from. Tested in plain Node |
| The pass | live/src/ticket.ts | Sign and check join and kick passes. Bundled by Convex AND the relay, so a change ships only when both deploy |
| Pass issuer and kick | convex/live.ts | Finds the player and signs the join pass. Runs the scheduled kick |
| Kick on removal | convex/lib/liveKick.ts, convex/lib/members.ts | Every player removal schedules one kick. Skipped when the backend has no relay settings |
| Phone connection | apps/app/src/live/connection.ts | One socket per room: hold, linger, retry, pause |
| The lobby's places | apps/app/src/modes/online/roomWire.ts, useRoomPlaces.ts | The 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 kit | apps/app/src/ui/reactions/ | The feed rules (send limit, checks, cap), the hook, the floating overlay and the bar |
| Board hook | apps/app/src/live/useLiveChannel.ts | The one thing a board calls |
| Batcher | apps/app/src/live/batcher.ts | Turns a fast stream into a few messages a second |
| Relay address | apps/app/src/live/config.ts | EXPO_PUBLIC_LIVE_URL, or the local relay in dev |
| Online wiring | apps/app/src/modes/online/useOnlineLive.ts | Makes the connection, closes it in the background |
| The contract | packages/engine types | BoardProps.live, LiveRoom, LiveMessage |
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
live/src/ticket.ts needs both deploys. Convex bundles
that file, so the backend and the relay must both ship it, or passes stop matching.LIVE_SECRET set fails and names it, and a relay whose secret is too short answers
"relay not configured".bun run live:dev
ws://localhost:8787. A dev build uses it when no address is set.live/.dev.vars (never committed), with the same value on the dev
backend.LIVE_HTTP_URL stays unset there. Its tests cover both sides.chat topic) is drawn as a tray at the foot of the
table and no longer carries "who voted" lines. Why: decisions,
2026-10-01.react, held only while the screen says so. Same device has none.4003). The relay also closes a flooding socket (1008), holds at most 2
sockets per player (4001), and wipes a room's storage when its last socket goes
(why).live.gamesforgroup.com passes short-lived room messages beside Convex,
first for Quick Draw's growing line. Convex signs a 2-minute pass, checked at connect. The channel is
open only while a feature needs it, and the relay stores nothing
(why, and what was rejected).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.