@homie-rocks/studio 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,575 @@
1
+ # Homie netplay contract, v1 (revision 2)
2
+
3
+ Status: **v1, revision 2** (2026-09-26). The wire version is `v: 1`. Everything revision
4
+ 2 added is either an optional field or a new message type, and both sides ignore types
5
+ they do not know. A change to the contract bumps `v` and keeps v1 working.
6
+
7
+ **What revision 2 added:**
8
+ - Interpolation measures transit time.
9
+ - A body-control table: reset, take, give, ack, and a host-movement mode with
10
+ prediction.
11
+ - A keyed state channel for slow world state.
12
+ - Seats cannot be locked by visitors who left, and spectators get the next free seat.
13
+ - The relay clamps `st`, presses and event rates, and caps sockets per room and per
14
+ address.
15
+ - A frozen host or replica is detected and replaced.
16
+ - A deploy keeps seats, host and round.
17
+ - Events can be addressed to one peer.
18
+
19
+ | File in `@homie-rocks/studio` | What it is |
20
+ |---|---|
21
+ | `netplay/NETPLAY.md` | This document. |
22
+ | `netplay/netplay.ts` | The game-side helper, imported as `@homie-rocks/studio/netplay`. One file, no dependencies. |
23
+ | `worker/room.mjs` | The relay's rules, with no transport. It needs no Node built-ins. |
24
+ | `worker/index.mjs` | The studio's site Worker: `room.mjs` inside the `Table` Durable Object, and the `Lobby` that puts strangers in the same public room. `homie-studio dev` runs the same code under `wrangler dev`. |
25
+ | `starters/gem-rush/` | Gem Rush, the reference game (canvas, about 700 lines). It has both movement modes. |
26
+
27
+ ---
28
+
29
+ ## 1. The shape
30
+
31
+ Every browser, phone or computer, loads the full game and renders it with its own
32
+ camera and HUD. The relay is the room's Table Durable Object (under `wrangler dev`
33
+ locally). It is a post office that remembers the last few letters, and it runs no game
34
+ code.
35
+
36
+ ```
37
+ snap 20 Hz (fast state + control table) fan-out
38
+ HOST ────────────────────────────────────────▶ RELAY ─────────────▶ REPLICA / SCREEN
39
+ rules, bots, ◀── in 20 Hz (avatar or intent) ─ (memory; ◀─ in ──── own body: moved locally,
40
+ clock ─── state k=v (slow, on change) ▶ storage ─ state ─▶ or predicted; others
41
+ ─── ckpt 1 Hz ──────────────────▶ only for interpolated from
42
+ seats and snapshots
43
+ a 30 s ckpt)
44
+ ```
45
+
46
+ - **One browser is the host.** It runs the rules: pickups, scoring, the clock, hits,
47
+ bots and round end.
48
+ - **Every seated body has a control entry**, kept by the host and carried in every
49
+ snapshot (section 8):
50
+ - `rs`: a reset epoch;
51
+ - `own`: who moves the body right now;
52
+ - `ack`: the last input the host had.
53
+
54
+ In **owner movement** the seat's own browser moves its body instantly, and the host
55
+ bounds it. In **host movement** the host's rules move the body from the seat's
56
+ intents, and the seat predicts its own body. The host can **take** any body, for a
57
+ knockback, a carry or a stun, and **give** it back.
58
+ - **Three channels by change rate:**
59
+ - **Snapshots** (20 Hz) carry fast state: live bodies and projectiles.
60
+ - **Keyed state** carries slow state: claims, doors, a scoreboard, zones. It is sent
61
+ only when it changes, kept by the relay, and handed to every joiner in its welcome.
62
+ - **Checkpoints** (1 Hz, relay memory) carry everything a new host needs.
63
+ - **The host migrates.** If the host leaves, stalls or freezes, the relay promotes
64
+ another browser. That browser gets the checkpoint, the newest snapshot, the state,
65
+ the round and the roster, and continues the same round.
66
+
67
+ ## 2. Roles
68
+
69
+ | Role | Who | Runs rules | Has a body | Sends | Receives |
70
+ |---|---|---|---|---|---|
71
+ | `host` | One browser per room | yes | if seated (a seatless host is possible) | `snap`, `state`, `ckpt`, `round`, `roster`, `ev` | `in`, `ev`, `join`, `leave` |
72
+ | `replica` | Every other seated browser | no | yes | `in`, `ev` | `snap`, `state`, `ev`, `round`, `roster`, `join`, `leave`, `seat` |
73
+ | `screen` | A spectator (a TV or laptop with `?screen=1`, or a visitor waiting for a seat) | no | no | `ev` (2/s, 512 B) | the same as a replica |
74
+
75
+ - **Role and seat are separate facts.**
76
+ - `net.isHost` answers: do I run the rules?
77
+ - `net.seat !== null` answers: do I have a body?
78
+ - A screen can be elected host when nobody else can host.
79
+ - **Offline is not a fourth role.** With no shell (a plain file, a Vite dev server),
80
+ the helper reports `role: 'host'`, `seat: null` and `offline: true`, and every send
81
+ is a no-op.
82
+ - **Roles change at any time.** A game must handle all three cases:
83
+ - its first role;
84
+ - promotion (a replica becomes host);
85
+ - demotion (a stalled, hidden or frozen host becomes a replica).
86
+
87
+ ## 3. Seats
88
+
89
+ | Fact | What it is | Notes |
90
+ |---|---|---|
91
+ | `seat` | An integer from 0 to `maxPlayers - 1`, stable per browser per room | The player identity. `null` for screens. |
92
+ | `token` | The capability that resumes a seat | Returned in `welcome` and never shown to others. The shell keeps it in `sessionStorage`. |
93
+ | `id` | One socket | Used for routing, event addressing and the host reference. It changes on every reconnect. |
94
+ | `name` | Display name | Typed by the guest, so untrusted: render it with `textContent` only. |
95
+ | `colour` | `seat % 12` | An index into a 12-colour palette. |
96
+
97
+ - **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
98
+ - **Room size.** `maxPlayers` comes from the manifest. On the site the Table reads it.
99
+ Locally, the first visitor of an empty room sends it in `hello.max`.
100
+ - **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
101
+ **A visitor who is present beats one who left:** when no seat is free, the relay
102
+ reclaims the seat that has been absent longest. Visitors who leave can never lock a
103
+ room.
104
+ - **A full room** admits the visitor as a waiting spectator (`full: true`). When a seat
105
+ frees up, the relay seats them:
106
+ - the new player gets `seat { seat, token, name, colour, role }`;
107
+ - everyone else gets `join` with the new peer.
108
+
109
+ The helper turns this into a role event with `why: 'seated'`.
110
+ - **The same seat opened twice** (two tabs, or a reload racing its own close): the newer
111
+ socket wins, and the older one gets `error replaced` and stops reconnecting.
112
+ - **A reload lands in the same body.** `Roster.claim()` gives a returning seat the slot
113
+ it last held, if that slot is still a bot. During the second or so of a reload, a bot
114
+ keeps the body moving.
115
+
116
+ ## 4. Lifecycle
117
+
118
+ **Instant start.** The first browser is elected host in its `welcome` and starts round 1
119
+ at once, with bots filling the slots (Gem Rush: 1 human + 2 bots).
120
+
121
+ **Bots yield to humans.** The host's `Roster` keeps the slots.
122
+ - A human who arrives (`join`) takes a bot's body where it stands; the host calls
123
+ `net.reset(seat)` so that browser adopts it.
124
+ - A human who leaves turns back into a bot in the same body.
125
+ - `trim()` removes surplus bots between rounds.
126
+ - After every change the host calls `net.roster(slots)`.
127
+
128
+ **Join in progress.**
129
+ 1. The `welcome` carries the last snapshot, round, roster and **all keyed state**. The
130
+ joiner draws the running world on its first frame.
131
+ 2. The host claims a slot and resets the body.
132
+ 3. The joiner's first snapshot carries its control entry, so it adopts its body.
133
+
134
+ A solo host sends one snapshot a second, so even a joiner's first welcome has a world in
135
+ it.
136
+
137
+ **Continuous rounds.**
138
+ - The host announces each phase with `net.round({ n, phase, startedAt, endsAt, results? })`.
139
+ All times are server milliseconds.
140
+ - `live` runs until `endsAt`. Then `over` (with `results`) runs until the intermission
141
+ ends, and then round `n + 1` starts.
142
+ - Round numbers only increase, including across host changes.
143
+
144
+ **Host election.**
145
+
146
+ | Event | What the relay does |
147
+ |---|---|
148
+ | No host | The first `canHost` hello becomes host. |
149
+ | The host closes or says `bye` | Sends `leave`, then elects a replacement. |
150
+ | **Stall:** the host has sent no snapshot for **1.5 s** while others are present | Demotes it (`role replica, why host-stalled`) and elects another. |
151
+ | **Frozen host at a newcomer's hello:** the host is hidden, or has sent nothing for 2.5 s | Makes the newcomer host at once. The old host gets `role replica`. |
152
+ | **Hidden tab** | The helper sends a checkpoint, then `yield`. `ping {hid: true}` has the same effect. |
153
+ | **Silent socket:** nothing received for **10 s** | Closes it (a slept phone, a half-open link). The seat's body turns into a bot. The helper pings every 2 s. |
154
+
155
+ - **Ranking candidates:**
156
+ 1. visible before hidden;
157
+ 2. responsive before silent;
158
+ 3. longer tenure first, in 30 s steps, so a newcomer cannot jump the queue by
159
+ calling itself a desktop;
160
+ 4. `desk` > `tv` > `phone`, as a tie-break;
161
+ 5. whoever arrived first.
162
+ - **The new host** gets
163
+ `role { role: 'host', why, ckpt, snap, round, roster, state, peers }`. Everyone else
164
+ gets `host`.
165
+ - **No voluntary migration back.** A former host that wakes up or reconnects comes back
166
+ as a replica.
167
+
168
+ **The helper's side of liveness.**
169
+ - A socket that has delivered nothing for 6 s (pongs come every 2 s) is dropped and
170
+ reopened with the token.
171
+ - A hidden tab reconnects when it becomes visible again.
172
+
173
+ **Empty rooms and deploys.**
174
+ - An empty room forgets everything after 60 s, and clears its storage.
175
+ - If the relay restarts with sockets connected, as a Worker deploy does, every browser
176
+ reconnects with its token and gets the **same seat**. The seat that was host gets 3 s
177
+ to reclaim the role.
178
+ - A host that reconnects as host re-sends its round, roster, state and a checkpoint.
179
+ - If the old host does not return, the relay elects someone else. That browser restores
180
+ from the stored checkpoint (at most 30 s old) and from its own newest snapshot.
181
+
182
+ ## 5. Wire protocol v1
183
+
184
+ - **Transport:** one WebSocket per game document, carrying JSON text frames
185
+ `{ "t": <type>, ... }`. The first client frame must be `hello`. Unknown types are
186
+ ignored in both directions.
187
+ - **Addresses:**
188
+ - a studio site: `wss://<host>/<game>/__net?room=<room>`;
189
+ - `homie-studio dev`: `ws://127.0.0.1:8787/<game>/__net?room=<room>`.
190
+
191
+ The shell puts the address in `window.HOMIE_NET.url`, so the game never builds it.
192
+
193
+ ### Client → relay
194
+
195
+ | `t` | Sent by | Fields | What the relay does |
196
+ |---|---|---|---|
197
+ | `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?` | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full` or `too-many`. |
198
+ | `snap` | host | `k`, `st`, `d`, `c?` (control table) | Clamps `st` to [now − 2 s, now + 250 ms] (a snapshot stamped in the future would freeze replicas). Stores the snapshot and fans it out with `from`. Skips a socket with more than 256 KB buffered. |
199
+ | `in` | seated non-host | `q`, `a`, `h`, `p?`, `r` (the reset epoch the sender has adopted) | Forwards to the host only, stamped `from: <seat>`. Presses are clamped to integers 0–8, at most 16 keys. |
200
+ | `ev` | anyone | `k`, `d`, `to?` | **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
201
+ | `state` | host | `k` (≤ 64 characters), `d` (`null` deletes) | Stores the key in memory, forwards it to all others, and includes the whole map in every `welcome` and in `role` to a new host. |
202
+ | `ckpt` | host | `k`, `st`, `d`, `c?` | Stores it in memory. It is saved to storage at most every 30 s. Not forwarded. |
203
+ | `round` | host | `round` | Stores it, forwards it, and tells the shell's watchers. |
204
+ | `roster` | host | `slots` | Stores it, forwards it, and tells the shell's watchers. |
205
+ | `ping` | everyone | `c`, `hid` | Replies `pong`. A host whose `hid` turns true yields. |
206
+ | `yield` / `bye` | host / anyone | — | `yield` elects another host if one is available. `bye` is a close. |
207
+
208
+ ### Relay → client
209
+
210
+ | `t` | To | Fields |
211
+ |---|---|---|
212
+ | `welcome` | the new client | `v`, `id`, `room`, `seat`, `token`, `name`, `colour`, `role`, `why: 'first'\|'resumed'\|'joined'\|'host-stalled'\|'host-hidden'`, `host`, `peers`, `st`, `max`, `round`, `roster`, `snap`, **`state`**, `ckpt` (host only), `full?` |
213
+ | `role` | a client whose role changed | `role`, `why`, `host`, `peers`. For a new host, also `ckpt`, `snap`, `round`, `roster`, `state`. |
214
+ | `seat` | a waiting spectator that just got a seat | `seat`, `token`, `name`, `colour`, `role` |
215
+ | `host` | everyone else, on a change | `host`, `why` |
216
+ | `join` / `leave` | everyone else | `peer` / `id`, `seat`, `why: 'closed'\|'bye'\|'replaced'\|'silent'\|'flood'` |
217
+ | `snap` | everyone but the host | `from`, `k`, `st`, `d`, `c?` |
218
+ | `in` | the host | `from`, `q`, `a`, `h`, `p?`, `r?` |
219
+ | `ev` | as routed | `from`, `k`, `d`, and `id` on events to the host |
220
+ | `state` | everyone but the host | `k`, `d` |
221
+ | `round` / `roster` | everyone but the host | `round` / `slots` |
222
+ | `pong` | the pinger | `c`, `st` |
223
+ | `error` | the offender | `code`, `message`, `of?` |
224
+
225
+ **Error codes.**
226
+ - Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
227
+ `too-many`, `flood`.
228
+ - Reported only: `too-large`, `rate`, `state-full`.
229
+
230
+ A socket that is kicked for silence is closed with code 4000, and the helper reconnects.
231
+
232
+ Record types:
233
+
234
+ ```ts
235
+ Peer = { id, seat: number|null, name, colour, device, want, role }
236
+ RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
237
+ RoundResult = { slot, seat: number|null, name, score, bot, place }
238
+ Slot = { slot, seat: number|null, name, bot }
239
+ ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c and ckpt.c
240
+ ```
241
+
242
+ **The shell's watch socket** (`/<game>/__watch?room=`) gets
243
+ `{ t:'net', room, host, clients, round, roster, snapHz, counts: {players, screens, waiting, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
244
+ about once a second and on every change.
245
+
246
+ **Game frame → parent page** (`postMessage { t: 'homie-net', what, ... }`). The values of
247
+ `what` are:
248
+ - `attached`, `token`, `role`, `stats` (2 Hz), `round`, `roster`;
249
+ - `closed` (with `why`: `replaced`, `version`, `room-full`, `too-many` or `flood`).
250
+
251
+ The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
252
+ so `ev.origin` is `"null"`.
253
+
254
+ ## 6. Size and rate budgets
255
+
256
+ | Frame | Target | Relay hard cap | Rate | Relay cap per client per second |
257
+ |---|---|---|---|---|
258
+ | `snap` | **< 2 KB, < 8 KB always** | 16 KB → `too-large` | 20 Hz (1 Hz while the host is alone) | 30 |
259
+ | `in` | < 256 B | 2 KB | 20 Hz, plus an immediate flush (≤ 60 Hz) on a press edge | 60 |
260
+ | `ev` | < 1 KB | 4 KB (512 B from a screen) | as needed | host 30, replica 10, screen 2 |
261
+ | `state` | < 2 KB per key | 8 KB per key; **64 keys and 64 KB per room** → `state-full` | only on change | 64 |
262
+ | `ckpt` | < 32 KB | 64 KB | 1 Hz | 4 |
263
+ | `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB | on change | 4 / 8 |
264
+ | `ping` | — | 256 B | every 2 s | 8 |
265
+
266
+ **Sockets.**
267
+ - At most `maxPlayers + 16` per room, and **12 per client address per room**. The Worker
268
+ passes `CF-Connecting-IP` as `conn.ip`.
269
+ - A client dropping more than 100 messages over the caps within 5 s is closed (`flood`).
270
+
271
+ **Keep it small.**
272
+ - Arrays per entity, and quantised floats (`q(x, 1)`).
273
+ - Never send idle slots.
274
+ - Slow state goes in `net.state()`, never in the snapshot.
275
+ - Host-only bookkeeping goes only in the checkpoint.
276
+
277
+ **Measured size.** Gem Rush snapshots are 364–427 B at 20 Hz.
278
+
279
+ ## 7. Clock sync and interpolation
280
+
281
+ - **Clock.**
282
+ - Pings measure `rtt = recv - c` and `offset = st + rtt/2 - recv`.
283
+ - The helper keeps the minimum-RTT sample of the last 8.
284
+ - It slews small corrections by ±5 ms per sample and jumps corrections over 100 ms.
285
+ - `net.now()` is the relay's clock, and every time on the wire is server
286
+ milliseconds.
287
+ - **Interpolation delay is measured, not assumed.** For every snapshot that comes off
288
+ the wire, the helper records its **arrival age**, `now() − st`. That age is transit
289
+ from host to relay to replica, plus any error in the clock offset. The delay is:
290
+
291
+ `delay = clamp(p90(age over the last 40 snapshots) + 1.2 × snapshot interval + 4 ms, 50, 400)`
292
+
293
+ - It rises by up to 6 ms per snapshot.
294
+ - **On an underrun** (a `sample()` past the newest snapshot) it grows at once by
295
+ exactly the overrun, up to 30 ms a frame. The picture was holding on the newest
296
+ snapshot anyway, so nothing jumps back, and the next snapshots continue smoothly
297
+ instead of catching up.
298
+ - It falls by 6% of the excess per snapshot, and at least 3 ms. So boot-time jank
299
+ clears in about a second, and nobody sees the time-warp.
300
+ - A snapshot processed in the same burst as the one before it is skipped as an age
301
+ sample. It waited behind the page's own stall, not on the network.
302
+ - Clock error cancels out, because render time uses the same `now()`.
303
+ - **Measured.**
304
+
305
+ | Link | Starved frames | Delay | Age p90 |
306
+ |---|---|---|---|
307
+ | 30–50 ms one way (end-to-end run, both replicas) | 0 of 241 | 148–157 ms | 84–93 ms |
308
+ | Same link, an independent reviewer's script | 5 of 241, worst 2 ms | 167 ms | — |
309
+ | Revision 1's formula, the same script | 102 of 241 (42%), worst 79 ms | 108 ms | — |
310
+ | Loopback | 0 | 65 ms (the floor is 50) | 1 ms |
311
+
312
+ On an overloaded machine (load average 14–18 on 10 cores), one run starved on 10–13%
313
+ of frames: arrival ages reached a p90 of 326 ms, and one page went 369 ms without
314
+ processing anything.
315
+
316
+ "Starved" means the render time ran past the newest snapshot.
317
+ - **Safety.**
318
+ - A snapshot stamped more than 1 s ahead of `now()` is rejected, and the relay clamps
319
+ `st` too.
320
+ - On a host change, the buffer rebases onto the new host's timeline, so a clock a
321
+ little behind the old host's is not dropped.
322
+ - `net.sample()` returns `{ a, b, alpha, renderT, starved }`. Interpolate per entity
323
+ with `lerp` / `lerpAngle`. `stats().starvedPct` reports how often it held.
324
+ - **Your own body is never interpolated.** It is moved locally in owner movement, or
325
+ predicted in host movement.
326
+
327
+ ## 8. Moving bodies: the control table
328
+
329
+ The host keeps one entry per seat and puts it in every snapshot and checkpoint as
330
+ `c: [[seat, rs, own, ack], ...]`. The helper maintains it; the game calls these:
331
+
332
+ | Host call | Meaning |
333
+ |---|---|
334
+ | `net.reset(seat)` | "I moved this body myself" (spawn, respawn, takeover of a bot, anti-cheat). It bumps `rs`, and the seat adopts the host's position. |
335
+ | `net.take(seat, ms?)` | "I drive this body now" (knockback, carry, grab, stun). The seat's avatar is ignored. With `ms`, the body is given back automatically. |
336
+ | `net.give(seat)` | Returns the body to the room's movement mode where it now is. It bumps `rs`. |
337
+ | `net.avatar(seat)` | The owner's avatar. It is `null` while the body is taken, in host movement, or when the frame predates the last reset. |
338
+ | `net.inputOf(seat)` | The latest raw frame: intents, buttons, and the raw avatar. |
339
+ | `net.control(seat)` | `{ rs, own, taken, ack }` |
340
+
341
+ | Replica side | Meaning |
342
+ |---|---|
343
+ | `on('control', e)` | Fires when my entry changes. `e.reset`: stand where `e.snap` has my body, and drop prediction. |
344
+ | `net.owned` | `true` while my browser moves my body. |
345
+ | `net.input(a, held)` | Called every frame. It is stamped with my `rs` automatically. |
346
+ | `net.pending()` | My input frames the host had not acknowledged in the newest snapshot, as `[{ q, a, h, at, dt }]`. |
347
+
348
+ **Two movement modes.** `createNetplay({ movement })` sets the default for every seat.
349
+
350
+ - **`owner`** (the default; Gem Rush).
351
+ - The seat's browser simulates its own body and sends it every frame.
352
+ - The host bounds it with `capMove(from, claim, maxSpeed × dt × 1.3 + slack)` every
353
+ host frame. A legitimate avatar catches up within a frame; a teleport crawls.
354
+ - When a claim is more than half a second of running away from the host's copy, the
355
+ host calls `reset()`. A cheater then either adopts the host's position or is
356
+ ignored. Measured end to end: a raw-socket cheater that follows the protocol but
357
+ claims gem positions scores **0–2 points in 3 s**, with 14 resets (revision 1:
358
+ 61 points).
359
+ - **`host`** (any game whose rules own movement).
360
+ - The seat sends **intents**. The host's rules move the body.
361
+ - The seat's browser **predicts**:
362
+ 1. it moves the body locally from its live stick, using the same rule as the host;
363
+ 2. when a snapshot lands, it takes the host's position, replays `pending()` on top,
364
+ and pulls 30% of the way toward that (or jumps if the error is over 150 units).
365
+ - Positions in `in` are never read, so there is nothing to teleport.
366
+ - Measured end to end at 30–50 ms one way:
367
+ - own response 11.7 ms (touch 16.8 ms);
368
+ - the host sees the move in 127 ms;
369
+ - the replica's own view converges to the host's view within 0.02 units;
370
+ - a raw-socket cheater scores 0.
371
+
372
+ **Knockback, hitstun, carries, grabs.** The host calls `take(seat, ms)` and simulates the effect itself. The owner
373
+ draws its body as the host has it; with prediction it stays smooth, and its input is
374
+ ignored. When the effect ends, the host gives the body back and the owner resumes from
375
+ the host's final position. The victim cannot ignore a hit, because the host holds the
376
+ pen. An impulse is simply a `take` whose effect the host simulates.
377
+
378
+ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
379
+ - taken 110 ms after the hit;
380
+ - held 415 ms;
381
+ - displacement 168 units;
382
+ - after the give, the owner and the host agree within 0 units (host movement: 1 unit);
383
+ - the owner moves again locally in 16.6 ms.
384
+
385
+ ## 9. Checkpoints and promotion
386
+
387
+ - **`createNetplay({ checkpoint: () => fullState })`.** The helper sends it every
388
+ `checkpointMs`, before `yield`, and on `pagehide`, wrapped with the control table.
389
+ - **The checkpoint holds everything the rules need:**
390
+ - round and roster;
391
+ - every body (position, score, bot brain), plus host-only bookkeeping;
392
+ - world state;
393
+ - the tick.
394
+
395
+ It does not need what is already in the keyed state.
396
+ - **On promotion, `role` carries** `ckpt` (up to 1 s old, complete), `snap` (up to 50 ms
397
+ old, newer), `round`, `roster` and `state`. If the relay has no snapshot (for example,
398
+ just after a restart), the helper hands over its own newest one, if it is under 5 s
399
+ old.
400
+ - **Restore in this order:**
401
+ 1. Take the checkpoint.
402
+ 2. Overlay the snapshot.
403
+ 3. Read keyed state with `net.stateOf()`.
404
+ 4. Take `round` if it is newer.
405
+ 5. Call `roster.reconcile(peers)`, then `reset()` every claimed seat.
406
+ 6. Set your own body to your local one.
407
+ 7. Call `net.roster()` and continue.
408
+
409
+ The helper rebuilds the control table itself, and a body taken mid-knockback is given
410
+ back.
411
+ - **What can be lost:** whatever changed after the newest snapshot (about 50 ms of
412
+ pickups or events). Rounds, clocks, scores, roster and state survive.
413
+
414
+ ## 10. What the shell does, and what the game does
415
+
416
+ ### The shell (the site's play page and Table: `worker/` in this package)
417
+
418
+ 1. **Seat every visitor at once**, phone and desktop, with no `gather`.
419
+ 2. **Boot the game frame immediately.** Use the sandboxed, opaque-origin frame, and
420
+ inject `window.HOMIE_NET = { v: 1, url, room, token?, name?, device, want }` ahead of
421
+ every module.
422
+ 3. **Serve the game's own files** beside the shell (`/<game>/__game/`), with `HOMIE_NET`
423
+ in its `index.html`.
424
+ 4. **Answer the arcade knock.** A game made with Homie's arcade controls asks
425
+ `GET …/__homie/call` for a Homie host; answer `{"error":"not-a-homie"}` so it stops.
426
+ 5. **Pass the remembered token in** (`?k=`), and save the token from `homie-net token`
427
+ per room.
428
+ 6. **Relay exactly as `worker/room.mjs` does.** The Table:
429
+ - keeps `lastSnap`, `lastCkpt`, `lastRound`, `lastRoster` and `state` as fields;
430
+ - **saves** the seat map and host seat whenever a seat is allocated or the host
431
+ changes, and the checkpoint, round, roster and state at most every 30 s (`store.save`,
432
+ one write each time);
433
+ - **restores** in `blockConcurrencyWhile`;
434
+ - **never** writes a netplay frame to storage.
435
+
436
+ `worker/index.mjs` is the working shape. Worth adding for a busy site:
437
+ - use hibernatable sockets (`ctx.acceptWebSocket`, with `serializeAttachment` for id,
438
+ seat, token, device, want, joinedAt);
439
+ - call `tick()` from an alarm;
440
+ - pass `CF-Connecting-IP` as `conn.ip`;
441
+ - take `maxPlayers` from the manifest;
442
+ - use separate storage keys, or a SQLite-backed object, if a save can pass 128 KiB.
443
+ 7. **Choose the device** with the short-edge rule (≤ 540 px is a phone; `?hand=`
444
+ overrides). `?screen=1` makes a spectator that shows a QR code to the game URL.
445
+ 8. **Show room facts** from the watch feed or the frame's messages: the lobby line,
446
+ results, and with `?debug=1` the debug strip.
447
+ 9. **One matcher:** every Play door for a game lands in the same rooms (the `Lobby`).
448
+
449
+ ### The game (a vendored `netplay.ts`)
450
+
451
+ 1. **Call `createNetplay({ game, maxPlayers, movement, checkpoint })` once** and render
452
+ from the first frame. Never wait for `ready`.
453
+ 2. **Handle `role` at any time.**
454
+ - Promoted: restore (section 9).
455
+ - Host but not promoted: start a fresh round.
456
+ - Demoted: stop the rules.
457
+ 3. **As host, every frame:**
458
+ - run the rules and bots;
459
+ - move bodies from `avatar()` with `capMove`, or from intents;
460
+ - `if (net.snapshotDue()) net.snapshot(fast, tick)`;
461
+ - `net.state(k, v)` when slow state changes (it dedupes);
462
+ - `round()` and `roster()` on changes;
463
+ - `Roster` on `join` and `leave`, with `reset()` on each claim.
464
+ 4. **When seated:** call `net.input([...avatar, ...intent], held)` every frame. Adopt
465
+ your body on `control` with `reset`. While `!net.owned`, predict (section 8).
466
+ 5. **Render others** from `net.sample()` and your own body locally. A spectator gets an
467
+ overview camera.
468
+ 6. **Draw your own HUD**: clock (`round.endsAt − net.now()`), scores, results.
469
+ 7. **Touch controls on phones:** a drag in the lower-left moves you. On desktop,
470
+ WASD and the arrow keys.
471
+ 8. **Expose probes** for end-to-end tests:
472
+ `net.expose({ self, peer, frames, scores?, zone?, owned?, debugKnock? })`.
473
+ 9. **Never gate the first frame on a tap.**
474
+
475
+ ## 11. Cost (Cloudflare Durable Objects)
476
+
477
+ - **The Table does three things:** fan-out, memory and elections.
478
+ - **No storage write per snapshot, input, event or state change.**
479
+ - **Storage writes are made:**
480
+ - per seat allocation or host change (rare);
481
+ - at most one checkpoint save per 30 s (≈120 writes/h, ≈ $0.0001/h at $1/M).
482
+ - **One busy room** (1 host + 3 replicas at 20 Hz): about 83 incoming messages/s, which
483
+ is ≈ 15 k requests/h at 20:1, or **≈ $0.002/h**. Duration (128 MB × 3600 s) is
484
+ **≈ $0.006/h**. **Total ≈ $0.008 per active room-hour.** Outgoing messages and egress
485
+ are not billed. There is no TURN server and no media relay.
486
+ - **Idle cost.** A socket that sends anything every few seconds keeps the object awake.
487
+ - A room with one idle visible tab costs the same ≈ $0.006/h, about $4 a month if
488
+ left open for good.
489
+ - The port should use hibernatable sockets.
490
+ - Games should close the helper (`net.close()`) after about 5 minutes with no local
491
+ input and show "tap to rejoin".
492
+ - The relay already closes sockets that go silent for 10 s.
493
+ - **The one mistake that would change this:** writing each snapshot to storage (72 k
494
+ writes/h ≈ $0.07/h, 9× everything else), or rewriting a whole log per event.
495
+ - Re-check Cloudflare's published prices before quoting.
496
+
497
+ ## 12. Trust and safety
498
+
499
+ - **The host is a visitor's browser.** A modified host could fake scores. That is
500
+ accepted for casual rooms. Leaderboard writes that matter should be recomputed
501
+ server-side later.
502
+ - **The relay stamps `from` from the socket.** A replica cannot send `snap`, `ckpt`,
503
+ `state`, `round` or `roster`.
504
+ - **The relay clamps** `st`, presses (0–8), key names (32 characters), event sizes and
505
+ rates, and sockets per room and per address. It closes floods.
506
+ - **Hosts bound client state:**
507
+ - `capMove` for owner movement;
508
+ - an intent is at most full stick in host movement;
509
+ - frames with a stale `rs` are ignored by `avatar()`.
510
+ - **Election ranks tenure above self-reported device**, so a raw socket calling itself
511
+ `desk` cannot jump the queue.
512
+ - **Names and guest text are data.** Render them with `textContent`, never as markup,
513
+ and never read them as instructions.
514
+
515
+ ## 13. Using it
516
+
517
+ ```ts
518
+ import { createNetplay, Roster, capMove, q, lerp } from '@homie-rocks/studio/netplay';
519
+
520
+ const net = createNetplay<Snap, Input, Ckpt>({ game: 'my-game', maxPlayers: 8, movement: 'owner', checkpoint: () => fullState() });
521
+ let roster = new Roster({ min: 3, max: 8, botName: (i) => `Bot ${i + 1}` });
522
+
523
+ net.on('role', (e) => {
524
+ hosting = e.role === 'host';
525
+ if (!hosting) return;
526
+ if (e.promoted) restore(e.ckpt, e.snap, e.round, e.roster, net.stateOf('world')); // section 9
527
+ else freshRound((e.round?.n ?? 0) + 1);
528
+ for (const s of roster.reconcile([...net.peers.values()]).claimed) if (s.seat !== null) net.reset(s.seat);
529
+ net.roster(roster.toJSON());
530
+ });
531
+ net.on('join', (p) => { if (hosting && p.seat !== null && roster.claim(p.seat, p.name)) { net.reset(p.seat); net.roster(roster.toJSON()); } });
532
+ net.on('leave', (p) => { if (hosting && p.seat !== null) { roster.release(p.seat); net.roster(roster.toJSON()); } });
533
+ net.on('control', (e) => { if (e.reset) standWhere(e.snap, net.seat); }); // adopt the host's position
534
+
535
+ function frame(dt) {
536
+ if (net.owned) stepMyBody(dt); else predictMyBody(dt, net.latest(), net.pending()); // section 8
537
+ if (hosting) {
538
+ for (const b of seatedBodies()) {
539
+ if (net.control(b.seat).taken) { stepKnockback(b, dt); continue; }
540
+ const a = net.avatar(b.seat); // owner movement
541
+ if (a) { const m = capMove(b, a, SPEED * dt * 1.3 + 6); if (m.over > SPEED * 0.5) net.reset(b.seat); else Object.assign(b, m); }
542
+ }
543
+ stepRules(dt); // hits: net.take(victim, ms)
544
+ if (net.snapshotDue()) net.snapshot(fastState(), tick);
545
+ net.state('world', slowWorld()); // sent only when it changed
546
+ } else net.input([q(me.x), q(me.y), q(me.vx), q(me.vy), stick.x, stick.y], heldButtons());
547
+ render(hosting ? authoritative : net.sample());
548
+ }
549
+ net.expose({ self: () => me, peer: (seat) => positionOf(seat), frames: () => frameCount });
550
+ ```
551
+
552
+ ```sh
553
+ npx --no-install homie-studio game new my-game --from gem-rush --name "My Game"
554
+ npm run dev # the studio's site at http://127.0.0.1:8787 (wrangler dev)
555
+ # open http://127.0.0.1:8787/my-game/play in two browsers: they land in the same room
556
+ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
557
+ # two fresh browsers (a computer and a phone) must share a room and finish a round
558
+ ```
559
+
560
+ ## 14. Known limits of v1
561
+
562
+ - **Replica-to-replica visibility goes through the host.**
563
+ - Measured at 242–330 ms at an RTT of 70–110 ms. Host to replica is 196–217 ms.
564
+ - The path: the input hop to the host, the host's snapshot interval, the transit to
565
+ the other replica, and that replica's interpolation delay, which is sized so it
566
+ does not starve.
567
+ - Forwarding each replica's avatar straight to the other replicas would save about
568
+ 100 ms. It would cost a second stream per peer and a second trust rule. It is a
569
+ candidate for v2, not in v1.
570
+ - **No lag compensation for hits.** A game can rewind a victim by the attacker's
571
+ `rtt/2 + delay` itself.
572
+ - **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B).
573
+ - **A reload shows a bot in the player's body for about 1–2 s**, and then the player
574
+ takes the same body back.
575
+ - **The host is a visitor's browser** (section 12).