@homie-rocks/studio 0.46.0 → 0.46.3

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +3 -2
  3. package/docs/rooms-milestone-2-notes.md +345 -0
  4. package/guides/game/RULES.md +115 -5
  5. package/guides/game/SKILL.md +5 -0
  6. package/lib/build-identity.mjs +43 -0
  7. package/lib/cloudflare.mjs +13 -11
  8. package/lib/crowd-cloud.mjs +88 -0
  9. package/lib/crowd-diagnostics.mjs +73 -0
  10. package/lib/crowd-load.mjs +603 -0
  11. package/lib/crowd-probe/README.md +61 -0
  12. package/lib/crowd-probe/client.mjs +96 -0
  13. package/lib/crowd-probe/worker.mjs +72 -0
  14. package/lib/crowd-probe/wrangler.jsonc +12 -0
  15. package/lib/crowd-summary.mjs +140 -0
  16. package/lib/crowd-worker.mjs +496 -0
  17. package/lib/rules-check-worker.mjs +1 -1
  18. package/lib/rules-check.mjs +2 -2
  19. package/lib/rules-types.mjs +1 -1
  20. package/lib/scaffold.mjs +5 -5
  21. package/lib/template-history.json +84 -0
  22. package/netplay/netplay.ts +19 -6
  23. package/package.json +4 -2
  24. package/rules/core.ts +29 -4
  25. package/rules/host.ts +119 -21
  26. package/rules/interest.mjs +320 -51
  27. package/rules/pack.ts +1 -0
  28. package/rules/rules.ts +29 -8
  29. package/rules/timing.mjs +20 -0
  30. package/rules/types.ts +5 -0
  31. package/rules/view.ts +229 -25
  32. package/saves/SAVES.md +83 -3
  33. package/saves/saves.ts +2 -0
  34. package/worker/build-id.mjs +2 -0
  35. package/worker/gate.mjs +436 -53
  36. package/worker/index.mjs +92 -10
  37. package/worker/office-page.mjs +11 -0
  38. package/worker/office.mjs +27 -0
  39. package/worker/players.mjs +13 -15
  40. package/worker/room.mjs +113 -19
  41. package/worker/saves-shell.mjs +22 -2
  42. package/worker/saves.mjs +282 -19
  43. package/worker/version.mjs +1 -1
  44. package/worker/world-wire.mjs +110 -0
package/CHANGELOG.md CHANGED
@@ -17,6 +17,111 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.46.3] - 2026-10-11
21
+
22
+ **Plugin 0.46.3**
23
+
24
+ A guest who signs in keeps both histories; a rival's sounds reach the players who can hear them; a shooter's own
25
+ actions answer at once; a tap moves as far as it was held; a client cannot stretch its own lag compensation.
26
+
27
+ ### Added
28
+
29
+ Every item here is opt-in: a game that declares none of them plays, costs and saves exactly as on 0.46.2.
30
+
31
+ - `mergeSaves({ '<game>': { saves: { '<key or prefix*>': rule }, stats: { … } } })` from
32
+ `@homie-rocks/studio/worker`: how a guest's saves combine with an account's when the guest signs in to an account
33
+ that already has the same key. Rules: `'account'`, `'guest'`, `'newer'`, `'larger'`, `'smaller'`, `'sum'`,
34
+ `'union'` (a list joined by id), or the game's own function, run on the server. It happens once, in the sign-in's
35
+ own transaction, guarded against a room writing a result at the same moment. The sign-in response says what
36
+ was merged, moved and kept (`adopted`), and a key no rule could combine reaches the game's `onConflict` with the
37
+ guest's value (saves/SAVES.md, "When a guest signs in to their account").
38
+ - Effects with an audience: `world.emit(effect, at, data, { within: metres, to: id | [ids] })` sends an effect only
39
+ to the players whose own body is in range or who are named; a watcher receives the ones with a range. For a sound a rival
40
+ could hear or a number only the shooter may see, in place of drawing it from state the rival should not have.
41
+ An effect with no audience is public and is sent as before.
42
+ - `f.held()`, an input type: the browser sends the average of the control over each tick, so a key held for part
43
+ of a tick moves the body for that part. Short taps no longer travel a whole tick or two by where the tick's
44
+ edges fell. An entry that reaches the server after its tick is settled on the next ticks, not counted twice.
45
+ - `openRoom({ predict: { <kind>(me, input, ctx) { … } } })`: the player's own actions predicted like their own
46
+ movement (run each predicted tick and between ticks, put right by every snapshot and re-run). `ctx.ghost` shows a
47
+ provisional entity and `ctx.effect` a provisional effect that the server's own then confirms (`confirms: true`)
48
+ or the room retracts (`room.on('retract', …)`). A valued pulse (`f.pulse()`) now names the tick it was pressed
49
+ on and the server acts on it in that tick; the control row says the newest pulse acted on, so a prediction
50
+ holds when the pulse's own message is late (as it can be through a Gate).
51
+ - `world.unitsLeft`: the budget units the running handler may still spend, so a handler that plans can stop early.
52
+ - `room.rewindSeconds` in rules: the game's hard cap on how far a person's ray is rewound for latency (from 0 to
53
+ `historySeconds`). Without it the cap is `historySeconds`, as before; `0.25` is a sensible value for a shooter.
54
+
55
+ ### Fixed
56
+
57
+ Each of these changes what happened before; none needs a change to a game.
58
+
59
+ - A guest with verified results who signed in to an account that also had some kept only the account's: the
60
+ guest's `server:` record was dropped, and nothing could combine the two afterwards because a browser cannot
61
+ write a `server:` key. Two server records are now joined by id when the guest signs in, with no rule declared.
62
+ - A result a room wrote for the guest a moment after they signed in was written under a player that no longer
63
+ existed. It now follows the guest to the account (`appendServerRecord`, at the same one read and one write).
64
+ - Lag compensation could be stretched by a client: the round trip was timed on a separate answer the client could
65
+ hold back, and only the smallest value ever seen was kept. It is now timed on the input frames themselves (each
66
+ names the newest snapshot its browser had), as a low percentile over the last ten seconds, and the allowance
67
+ counts the whole ticks an input waited after it arrived instead of a fixed one. A client still cannot look
68
+ faster than it is; one that pretends to be slower is bounded by `room.rewindSeconds`.
69
+
70
+ ### Upgrade notes
71
+
72
+ - Nothing to do for a studio that declares none of the above. To use `mergeSaves`, add it to
73
+ `site/src/worker.mjs` beside `hostRules`.
74
+ - A page from before this version that is still open against the new Worker is timed the old way until it
75
+ reloads. Its rewind is bounded by the same cap.
76
+ - All of the above works the same through Gates (a room of more than 64 seats) as in a small room; each has a
77
+ seeded test both ways. Through a Gate a held input that misses its tick can be drawn a few centimetres short for
78
+ a tick or two before it settles.
79
+ - Version 0.46.3 is provisional. No deploy or merge is part of this PR.
80
+
81
+ ## [0.46.2] - 2026-10-11
82
+
83
+ **Plugin 0.46.2** · Big rooms: the room runs the rules, its Gates do the rest.
84
+
85
+ A room of hundreds has a new shape, a deploy the owner asked for is approved by the asking, and Homie paces no input of a server-hosted game.
86
+
87
+ Measured on Cloudflare with a game where every player sees about 180 others: 300 players at 20 frames a second from a home connection; 600 at 20 frames a second measured from inside Cloudflare, marginal from one home connection carrying all 600; 1,000 are admitted and the room keeps its ticks, but delivery is not there yet. On the same game 0.45.0 delivered 13 frames a second at 300 and lost every player at 1,000.
88
+
89
+ ### Added
90
+
91
+ - `room.rates` in game.json: a studio's own pace on what one player may send, in messages a second by kind (`{ "in": 60 }`). Leave it out and there is none.
92
+ - `lib/crowd-probe/`: a Worker of its own that plays simulated players against a studio from inside Cloudflare, five a request, and `lib/crowd-cloud.mjs`, which adds the requests up into the report the home driver writes. A trial's tool: no studio's Worker imports it.
93
+ - Each Gate measures the room's clock against its own, the room's ticks a second by its own clock, and the longest it went without a world. The room's own count of its ticks cannot see the room run late.
94
+ - A big room's diagnostics come from an object that is neither the room nor a Gate with players. The room and each Gate post a second's facts to it (ticks a second, how long a player's input took to reach the rules, frames written to each player, how old the world was when a Gate sent it, how far behind players say they are), and `/_studio/api/room` answers from that copy with its age. Measuring a room costs it nothing, and a room too busy to answer still has its last word read.
95
+ - A player's client says which tick it holds (`g` on its inputs and pings). A Gate that finds a player more than a second behind sends that player fewer, fresher frames, down to one a second, and restores the rate when they have caught up. Each frame is a change from the last one sent and carries the player's own body. A client that says nothing is sent every tick, as before.
96
+
97
+ ### Changed
98
+
99
+ - In a room behind Gates the room object runs the rules and hands each Gate one world a tick. The Gate chooses what each of its players can see, encodes it and writes it. The room no longer selects, encodes or queues anything per player on its tick.
100
+ - A Gate holds fifty players and Gates fill in order: the room tells the door which Gate has room, so fifty players in a 1,000-seat game are one Gate and a full room is twenty. A studio sets its room's size and nothing else. A room of 64 or fewer is still its Table alone.
101
+ - The world crosses from the room to a Gate as one block of numbers, the same block for every Gate, plus whatever else about a body changed since that Gate last heard: about 33 KB a tick for 1,000 players where the JSON was 150 KB, and almost nothing for the Gate to parse.
102
+ - A body that keeps its course is not sent again. A client built with 0.46.2 moves it along its last velocity, and a Gate sends it only when that has drifted past the game's `view.precisionM` (near bodies) or half the distance it covers between `view.farHz` refreshes (far ones). On the crowd-circuit game at 1,000 players a frame falls from 71 rows to 20, a player from 14.8 KB a second on the wire to 6.3, and a far body is drawn 3 cm from the truth on average where it was 35 cm. A game page built before 0.46.2 is sent every moving body, as before.
103
+ - A Gate works out once for each world what does not depend on the player. Encoding a player's frame takes under a third of the time.
104
+ - Each Gate has its own channel to the room. The relay between them (the Concentrator) is gone: it re-read and re-sent every world for eight Gates and was the first object to fall behind. A studio's `CONCENTRATOR` binding stays declared and unused, so an upgrade needs no migration.
105
+ - A Gate sends the room what its players sent at once when the channel is quiet and never more than one frame in 50 ms, however many players it holds. Receipts ride that frame.
106
+ - Far-view refreshes are spread across players, so a room's largest updates do not all leave on the same tick.
107
+ - A deploy requested in the owner's prompt is approved for that task, including retries. Unrequested deployments remain held; explicit refusals revoke the task approval.
108
+ - The crowd probe offers WebSocket compression, as every browser does, coasts, names the tick it holds, follows the room's word on how early its inputs arrive (as the toolkit's client does), and reports whether the server agreed to compress, what that saved, and the kilobytes a second a player cost.
109
+
110
+ ### Fixed
111
+
112
+ - Homie no longer paces what a player sends a game whose rules run on the server. 0.45.3 dropped honest input at 60 a second when a slow network delivered a batch late. What players say to each other keeps its pace, and so does every room a player's own browser hosts.
113
+ - A game with fields only their owner may see keeps each player's view in the room when the room is behind Gates. A Gate was handed the whole state.
114
+ - Late callbacks that catch up at the requested tick rate no longer end a healthy room as an overrun; sustained slow ticks still stop.
115
+ - Relay failures log their layer, close cause and queue counters. A link's receipt latency measures the link, not the cadence a receipt waited for. Frozen Cloudflare clocks are not reported as zero execution time.
116
+ - Deployment summaries no longer assume Workers Free. A big room's estimate counts its Gates and its diagnostics object and says which request traffic it leaves out.
117
+
118
+ ### Upgrade notes
119
+
120
+ - Rebuild and deploy a studio to get the new room shape; a game needs no change. A game page built before 0.46.2 still plays in a big room: it is sent every tick and every moving body, because it says neither which tick it holds nor that it coasts.
121
+ - A room of 64 seats or fewer, and every room a player's browser hosts, is as it was: none of this is on its path. The one change that reaches a small server-hosted room is that Homie no longer paces what a player sends it.
122
+ - To measure your own game on your own Cloudflare account: `lib/crowd-load.mjs` plays a crowd from your computer and `lib/crowd-probe/` from inside Cloudflare. `lib/crowd-probe/README.md` has the commands and how to delete the probe afterwards.
123
+ - What four trials on Cloudflare measured, what is proven and what is not, is in `docs/crowd-transport-0462.md`.
124
+
20
125
  ## [0.46.0] - 2026-10-10
21
126
 
22
127
  **Plugin 0.46.0**
package/README.md CHANGED
@@ -206,8 +206,9 @@ game its studio chooses to share, each with its own licence ("Game parts", below
206
206
 
207
207
  `players.max` in game.json is the room's chosen positive integer capacity; omitted means 8.
208
208
  There is no 32-player ceiling or default per-address admission limit. Public games use
209
- rules plus view with `room.host: "server"`. Gates carry the browser connections and
210
- concentrators combine Gate links for larger rooms. Server rules survive browser departures.
209
+ rules plus view with `room.host: "server"`. Gates carry the browser connections, each on
210
+ its own channel to the room: the room runs the rules and hands every Gate one world a tick;
211
+ the Gate selects, encodes and paces each player's view. Server rules survive browser departures.
211
212
  New browser/offline games use the same rules contract. Existing netplay games and their
212
213
  port host helpers remain supported for upgrades, with their original 32-seat limit.
213
214
  For larger rooms, follow `guides/game/REWRITE.md`.
@@ -0,0 +1,345 @@
1
+ # Rooms milestone 2: releasable slices
2
+
3
+ The opening sections preserve the released slice-1 record. The 0.45.0 slice-2
4
+ implementation, local measurements and remaining work follow below.
5
+
6
+ 0.44.0 is slice 1, **spatial state delivery**. It is useful independently in an
7
+ existing server-hosted room: the studio chooses `room.view.radiusM` in game.json,
8
+ and a player's view receives nearby entities and its own body. Unchanged state
9
+ is compressed as deltas. No change to rules or movement is needed. `room.each`,
10
+ `enter` and `leave` describe the visible set, so a view must dispose of departed
11
+ meshes and recreate returning ones. Leaving view does not mean despawning.
12
+
13
+ This first PR does not claim that the public join/build path admits hundreds.
14
+ That path retains milestone 1's 32-seat restriction until slice 2 supplies Gates
15
+ and removes it. The simulation and relay now handle a 300-seat test directly,
16
+ without a simulation seat clamp or truncated rosters/control rows. This is the
17
+ foundation for the next slice, not a substitute for its admission and transport
18
+ proof. No deployment or merge was made.
19
+
20
+ ## What ships first
21
+
22
+ - `room.view.radiusM`: a studio-selected radius in metres, including z in 3D.
23
+ Omitted or null means the whole room, with the existing small-room path and
24
+ no extra per-client encoding. Zero means only colocated entities. No default
25
+ radius, downstream budget, spend limit, or crowd admission policy is imposed.
26
+ - The real relay filters both ongoing snapshots and join/rejoin welcome state.
27
+ A player with no body yet sees no entities. Watchers retain the whole-room
28
+ overview in this slice; following a camera does not restrict their feed.
29
+ - Each connection retains one keyframe. Deltas carry changed entity components,
30
+ new entities and explicit removals, against that keyframe. They carry the
31
+ current round and only the receiving player's input acknowledgment/control row.
32
+ - A keyframe at least once per simulation second, and a fresh one at every
33
+ connection or epoch change. Backpressure skips snapshots without advancing the
34
+ encoder. A missing intermediate delta cannot corrupt later updates. A lost
35
+ keyframe causes deltas to be ignored until the next keyframe, at most one
36
+ simulation second later. WebSocket itself is ordered and reliable.
37
+ - Saving and host recovery still use complete authoritative state. Filtering
38
+ never changes simulation, collision, queries, scoreboards or shared state.
39
+ - Seeded, virtual-time correctness in `npm test`; the long 300-client run is in
40
+ `test:rules:extended`. The pre-existing 12-scenario real-time traffic matrix and
41
+ 27 handover repetitions move there too, with all assertions retained. The gate
42
+ keeps two actual-browser cases (tab hiding and socket closing); virtual time
43
+ covers the rate/seed/handover matrix. No wall-clock throughput threshold is a
44
+ release gate.
45
+
46
+ Interest is a bandwidth feature, **not hidden information**. Shared state,
47
+ effects, roster and watcher overview are still public. Private fields and tells
48
+ arrive together in slice 3. Browser hosting continues to send complete state;
49
+ requesting a radius there produces a build diagnostic. The existing starters
50
+ need no contract rewrite for slice 1 and keep their whole-room views.
51
+
52
+ ## Design decisions and departures
53
+
54
+ The roadmap's sections 5, 6, 7, 10 and 23 are the starting point. Milestone 1
55
+ already separates the host, core, transport-free relay and view. We reuse those
56
+ roles instead of first splitting Table into new abstractions or declaring empty
57
+ Durable Object classes. Gate and concentrator classes arrive together in slice 2,
58
+ when they can actually carry connections and their deploy can be tested.
59
+
60
+ Interest/deltas precede Gates: doing this work in the existing relay gives a
61
+ shippable feature and a measurable role to move. The codec is independent of
62
+ Cloudflare and does not introduce a second runtime or contract profile. A small
63
+ game continues to see the match contract without `uses` or `profile` boilerplate.
64
+
65
+ Deltas name periodic keyframes rather than retaining an acknowledged history
66
+ per player. One baseline bounds memory, intermediates may be skipped safely, and
67
+ reconnect always starts fresh. This trades retransmitted changes and up to one
68
+ simulation second of recovery after a lost keyframe for a much smaller protocol.
69
+ It is not the roadmap's final bandwidth scheduler; near/far tiers, priorities,
70
+ short IDs, watcher follow feeds and shared far encodings remain slice 3 work.
71
+
72
+ The roadmap's default 96-metre visibility radius, default crowd cap from S6,
73
+ per-client bandwidth budget and guest-address cap are not defaults we will
74
+ adopt. They contradict the owner's no-imposed-limits rule. Studios may choose
75
+ limits explicitly. Cloudflare limits and measured capacity are reported as
76
+ information, never turned into a Homie admission ceiling.
77
+
78
+ ## Local proof
79
+
80
+ The workload uses the checked and guarded rules loader, real host runtime and
81
+ NetRoom relay, with 300 simulated WebSocket clients, one actual player body per
82
+ seat, 20 Hz seeded inputs, a 20-by-15 grid at four-metre spacing and a chosen
83
+ 12-metre radius. Each player moves at up to 2 m/s and increments a field each
84
+ tick. All 300 input acknowledgments, own bodies and roster entries are checked;
85
+ there are no rules errors or cut ticks. The fixture explicitly chooses a
86
+ 5,000,000-unit tick budget; this is not a new default or a general game capacity
87
+ claim. Warmup is 20 ticks. Clients keep one baseline and latest state, not a log
88
+ of every received frame.
89
+
90
+ Measurement includes input dispatch, rules, encoding, serialization and all 300
91
+ clients parsing and decoding in the same Node process. Memory is peak sampled
92
+ whole-process heap/RSS, including the harness and rules loader, not isolated
93
+ server memory. Bytes count all downstream frames during the measurement window,
94
+ including snapshot JSON and pings; WebSocket/TLS framing is not included. These
95
+ are **local numbers, not Cloudflare measurements**, not Internet latency, and
96
+ not a rendering or physical-phone capacity result.
97
+
98
+ Measurement command:
99
+
100
+ ```sh
101
+ node --input-type=module -e 'import {interestWorkload} from "./packages/studio/test/rules-interest-workload.mjs"; console.log(JSON.stringify(await interestWorkload({ticks:1200,measure:true}),null,2))'
102
+ ```
103
+
104
+ Observed on macOS arm64, Node 22.23.3, 1,200 measured ticks (60 seconds of
105
+ virtual play), plus 20 warmup ticks. Other local verification was running; CPU
106
+ timings are observations, not a throughput guarantee.
107
+
108
+ | Measurement | Local result |
109
+ | --- | ---: |
110
+ | Clients / tick rate | 300 / 20 Hz |
111
+ | Peak visible bodies for one client | 33 |
112
+ | Tick including relay and all client decoding, p50 / p95 / p99 | 14.88 / 21.04 / 25.41 ms |
113
+ | Largest measured tick | 31.98 ms |
114
+ | Downstream per player, mean / highest | 23,171 / 29,117 bytes/s |
115
+ | Peak sampled whole-process heap / RSS | 133,426,952 / 238,764,032 bytes |
116
+
117
+ This does **not** meet the roadmap's eventual 16 KB/s target. Tiers, priority and
118
+ shared encodings in slice 3 have work to do; no target was weakened or represented
119
+ as passed. All 300 clients are simulated, not 300 browser tabs or Cloudflare
120
+ connections.
121
+
122
+ ## Release verification
123
+
124
+ - Focused codec, relay and rules tests: 20 passed, no skips.
125
+ - Real view client on virtual time: 189 passed, including the new spatial
126
+ delivery/lost-keyframe/reconnect case. Existing Chrome rendering smoke tests
127
+ run in the root gate; this slice adds no rendering-specific browser case.
128
+ - Plugin: 123 passed, one optional live-URL fixture skipped.
129
+ - Plugin validation, packed Desktop server and release preflight passed;
130
+ preflight lists studio 0.44.0 as the sole new npm version.
131
+ - Template and history generation, changelog sync/check completed.
132
+ - Root `npm test` runs with `CHROME_PATH` pointing to local Chrome. Final root
133
+ results and all six CI checks are tracked on [PR #83](https://github.com/homie-rocks/homie/pull/83).
134
+ - A same-role reconnect now delivers its welcome snapshot to the rules view,
135
+ even though no role-change event fires. The regression blocks later periodic
136
+ keyframes and verifies that welcome-based deltas advance the view immediately.
137
+
138
+ ## Remaining slices, in order
139
+
140
+ Each is a separate releasable commit series/PR after its predecessor is released.
141
+ Slices 1 and 2 are implemented. Slice 1 shipped in 0.44.0; this PR prepares
142
+ slice 2 as 0.45.0 on the released 0.44.1 base.
143
+
144
+ 1. **0.44.0 — spatial state delivery**, above. Proof: codec oracle, real views
145
+ on virtual time, loss/reconnect, and 300-client host/relay workload.
146
+ 2. **0.45.0 — Gates and bigger public rooms (implemented below).** Gate/concentrator classes together,
147
+ reuse the delivery role, batch input and preserve commands, authenticated binding-only admission,
148
+ resume/revocation, and automatic layout from `players.max`. Remove public seat
149
+ caps, make guest policy studio-selected, report tested size in build/deploy
150
+ plans. Prove 300 clients through the actual join path, Gate restart/resend,
151
+ 20 Gates through concentrators, and small rooms without extra objects. Preserve
152
+ existing netplay builds, checks, deploys and host/port helpers at their original
153
+ 32-seat limit. New games, starters and authoring guides use rules plus view,
154
+ including browser/offline mode.
155
+ 3. **Private and scheduled delivery.** Field `see`, reliable one-player `tell`,
156
+ optional studio-chosen downstream budget and priorities (near/far visual
157
+ scheduling shipped in slice 2),
158
+ watcher follow/region and overview feeds, crowd rendering guidance/helpers.
159
+ Update starters where these capabilities apply. Prove no owner/server field
160
+ reaches another player's bytes, tells survive reconnect once, boundary
161
+ enter/leave and fairness under a chosen budget; measure a dense crowd and
162
+ rendering in real browsers. A view leaving interest is not evidence of privacy.
163
+ 4. **Crowd policies and vehicles.** Studio-selected `crowd.cap` with queue/hold,
164
+ soft event work scheduling without dropping work, `uses`, solid bodies,
165
+ attach/control/detach and driver prediction. Prove cap absent means no imposed
166
+ cap; explicit queues/holds release correctly; four-person vehicle prediction,
167
+ seat contention, disconnect and restore. Ship one vehicle example and update
168
+ the skill in the same slice.
169
+ 5. **Large worlds' files.** Extend existing studio-owned R2 asset serving to game
170
+ files over 25 MiB and multipart upload; streamed map chunks, zones, compiled
171
+ triangle meshes and navigation. Reuse the existing upload/build machinery.
172
+ Prove large asset integrity, interrupted/resumed multipart upload and map
173
+ streaming with the same collision data on server and client.
174
+ 6. **Milestone proofs and authoring tools.** Single-machine swarm CLI, match kit,
175
+ cross-browser-engine movement hashing, S6 at 20/60 Hz and multiple regions,
176
+ S11 transport assessment, dense crowd tuning and remaining authoring trials.
177
+ Local correctness stays seeded/virtual; long runs stay extended. The roadmap's
178
+ 300-bot, 30-minute Cloudflare exit test, Gate recovery latency, physical phone
179
+ rendering and actual billing need a separately authorized deployment/device
180
+ session. No local receipt here is represented as those results.
181
+
182
+ ## Slice 2 (0.45.0): public rooms in the hundreds
183
+
184
+ Implemented:
185
+
186
+ - `players.max` is the single build size, read by public admission, compilation
187
+ and deploy planning without the 32-seat clamp. Shared carrier addresses have
188
+ no automatic player ceiling.
189
+ - Gate and Concentrator classes carry ordered, batched logical connections over
190
+ binding-only links. Public authentication remains in the Worker. Small rooms
191
+ embed the delivery role in the Table; larger layouts use one logical Gate per
192
+ 64 seats and concentrators above eight Gates. These are routing partitions,
193
+ not admission limits. The template generates both bindings and migrations.
194
+ - Link loss closes downstream sockets for normal token-based seat resumption.
195
+ Gates do not persist simulation or become a second authority. Gate passes are
196
+ internal binding requests, not independently client-supplied authorizations.
197
+ - Ordered snapshot deltas, shared spatial indexing and cached wire rows reduce
198
+ repeated work. Optional `precisionM`, `nearM`, and `farHz` schedule remote
199
+ visual state; controlled bodies remain exact and independently recoverable in
200
+ every frame. A lost remote delta cannot stall the local body or collider
201
+ revisions. Snapshot exits remain immediate.
202
+ - Revision 12 carries roster patches after a full welcome roster. A reconnect
203
+ storm previously repeated full rosters to every player. The patch baseline is
204
+ the last transmitted roster, separately from policy relabelling.
205
+
206
+ A 300-player manifest (the game still needs appropriate arena,
207
+ spawning, rule cost and rendering):
208
+
209
+ ```json
210
+ {
211
+ "players": { "min": 1, "max": 300 },
212
+ "room": {
213
+ "host": "server",
214
+ "view": { "radiusM": 12, "precisionM": 0.01, "nearM": 4, "farHz": 5 }
215
+ }
216
+ }
217
+ ```
218
+
219
+ The visual settings are examples, not imposed defaults or information privacy.
220
+ The workload explicitly chooses a 5,000,000-unit tick budget as in slice 1.
221
+
222
+ ### Local measurements
223
+
224
+ All results below are **local**, not Cloudflare, billing, Internet latency or
225
+ physical-phone results. Bytes exclude WebSocket/TLS framing. Heap/RSS include
226
+ clients and the test harness in the same Node process. Other work on this
227
+ machine affects CPU timing; bytes are deterministic for the virtual workload.
228
+
229
+ | Workload | Players | Mean bytes/player/s | Tick p50 / p95 ms | Peak heap / RSS bytes |
230
+ | --- | ---: | ---: | ---: | ---: |
231
+ | Unmodified 0.44.0, 1,200 virtual ticks | 300 | 23,171 | 12.23 / 16.72 | 134,431,008 / 238,534,656 |
232
+ | Final scheduled/delta delivery, 1,200 virtual ticks | 300 | 9,937 | 10.57 / 15.59 | 129,070,360 / 232,275,968 |
233
+ | Final scheduled/delta delivery, 120 virtual ticks | 1,000 | 10,258 | 33.88 / 52.12 | 190,297,048 / 328,974,336 |
234
+ | Gate links, 4 Chrome pages, reconnect churn and host restore, before roster patches | 300 | 88,444 | 15.79 / 73.47 | 142,211,400 / 375,734,272 |
235
+ | Same mixed transport workload, with roster patches | 300 | 13,623 | 13.52 / 25.33 | 154,397,384 / 274,808,832 |
236
+
237
+ Steady-state downstream falls about **57%** at 300. The two preliminary mixed
238
+ runs include recovery traffic and measured a host wake (potentially several
239
+ ticks); they are not directly comparable to the steady-state pair or the final
240
+ single-tick measurements below. They did not declare rounds. The final harness
241
+ declares a ten-minute round and verifies that failures preserve it.
242
+
243
+ The mixed harness uses the real Gate/Concentrator classes, rules loader,
244
+ host and NetRoom, external local WebSockets and four Chrome pages rendering
245
+ received bodies, with in-memory links standing in for Durable Object bindings.
246
+ Actual public Worker routing and workerd eviction are checked separately below.
247
+
248
+ Commands:
249
+
250
+ ```sh
251
+ node packages/studio/test/rules-crowd-local.mjs 300 60
252
+ node packages/studio/test/rules-crowd-local.mjs 1000 60
253
+ node --test packages/studio/test/rules-gates.test.mjs packages/studio/test/rules-interest.test.mjs
254
+ ```
255
+
256
+ The sustained, real-time 300/1,000 mixed runs live in `test:rules:extended`,
257
+ which runs files sequentially so load experiments do not compete with each other.
258
+ Virtual-time tests cover batching/order, 1,000 logical admissions, link cleanup,
259
+ spatial boundaries, quantisation, far scheduling and keyframe recovery.
260
+
261
+ ### Admission, compatibility and recovery proofs
262
+
263
+ Existing browser-hosted netplay games retain the build and runtime path from
264
+ 0.44.1, including host helpers, HUD and port import. Their manifest precedence
265
+ and 32-seat limit remain; larger requests print the limit and rewrite guide.
266
+ The generated Worker supplies Gate/Concentrator exports for pre-Gate studio
267
+ entries without rewriting custom Worker source. The release gate uses unchanged Ember Vale files from released 0.32.1, packs and
268
+ installs the current package, builds it and runs the real two-browser check
269
+ through a completed round. Starters/docs teach rules plus view only.
270
+ Migrating the rules fixtures exposed missing TypeScript resolution for shared parts;
271
+ the guarded checker now resolves those same declared parts as the bundler.
272
+
273
+ The public Worker test verifies its actual authorization and capacity selection
274
+ for 300 and 1,000 against Gate bindings, including a private-door refusal and
275
+ ignoring caller-supplied capacity/hosting. Real local workerd tests additionally
276
+ prove 20 Gate binding links through concentrators and a Gate abort, and the full
277
+ public Worker → Gate → Table/SQLite route: 40 seats in a declared 300-player room,
278
+ then Gate death and Table death, with every token resuming its original seat.
279
+ The extended Workerd case also passed all **1,000 actual public admissions**
280
+ across 16 Gates/two concentrators, then a Gate abort (59 returning clients) and
281
+ a Table abort (all 1,000 returning clients), retaining every seat. That fixture
282
+ runs at 1 Hz and is a storage/admission proof, not a 20 Hz capacity benchmark.
283
+ These are functional proofs; the measured full crowds below use in-memory
284
+ internal binding links as described above, not a Cloudflare deployment.
285
+
286
+ Both declared-round mixed receipts now pass:
287
+
288
+ | Local mixed run | Bytes/player/s, all measured traffic | Tick p50 / p95 / max ms | Peak Node heap / RSS bytes | Churn |
289
+ | --- | ---: | ---: | ---: | ---: |
290
+ | 300 players, 4 Chrome pages, 62.93 s | 10,463 | 10.21 / 31.54 / 142.32 | 206,609,760 / 313,114,624 | 15 |
291
+ | 1,000 players, 4 Chrome pages, 101.02 s | 9,203 | 32.97 / 59.29 / 188.74 | 314,542,056 / 573,784,064 | 50 |
292
+
293
+ Each run recovered all players after both failures within the same live round;
294
+ simulated clients retained their seats, and all four real pages delivered live
295
+ snapshot receipts after recovery. The final runs also verify each simulated and
296
+ real client's own body after recovery. The 300 run's initial steady 20-second window
297
+ was 9,274 bytes/player/s; the 1,000 run measured 7,931 over its first 20.19 seconds.
298
+ Recovery-window averages include disconnected intervals,
299
+ so the seeded virtual comparison above is the fair before/after bandwidth pair.
300
+ Mixed tick timing is now one host tick through snapshot enqueue; earlier mixed
301
+ numbers measured a host wake, potentially several ticks. Browser process memory
302
+ is excluded from Node heap/RSS. The 300 run averaged 19.05 simulation ticks/s
303
+ including failures; the 1,000 run averaged 16.27. CPU timings vary with other
304
+ machine activity; 1,000 p95 exceeds a
305
+ 50-ms tick period, so this is not a claim of guaranteed 20 Hz on Cloudflare.
306
+
307
+ The earlier failed 1,000 run is superseded. Fixes isolated a single failed
308
+ client send from its entire Gate, compacted identical fanout rows on internal
309
+ links, and made the restart harness reconstruct both saved relay seats and the
310
+ host (as a Table restart does) instead of rejoining every old connection just
311
+ before disconnecting it. Final root and six-check CI results are tracked in
312
+ [PR #85](https://github.com/homie-rocks/homie/pull/85). No deployment or merge
313
+ was performed.
314
+
315
+ Competing full-suite and load runs triggered the real Workerd overrun guard
316
+ and a mixed-run recovery timeout. They are failed stress attempts, not capacity
317
+ receipts. The successful full Workerd rerun kept that guard enabled and ran
318
+ without our other load runs. No Cloudflare CPU, memory or network guarantee is
319
+ inferred from these local results.
320
+
321
+ The scripted recovery checks include deliberate observation waits. All clients
322
+ were back when checked at 20.08 s (Gate) / 20.74 s (rules) at 300, and
323
+ 20.10 s / 60.00 s at 1,000. These are upper bounds, not minimum recovery latency.
324
+ The 1,000-client full restart is visibly expensive; faster crowd reconnection
325
+ and deployed latency measurements remain work for the milestone proof slice.
326
+
327
+ ## Later step: retire browser-hosted netplay
328
+
329
+ Retirement is not part of 0.45.0. Its preconditions are that every game in
330
+ Homie's own three live studios has moved to server rules, and a release has
331
+ already shipped a one-command assisted rewrite for other studios. Until both
332
+ are met, existing games must continue to build, pass check, run and deploy,
333
+ with the legacy release gate protecting that promise. New games remain rules
334
+ games only.
335
+
336
+ ### Upgrade proof on the real studio copy
337
+
338
+ Copied `/Users/ryan/Studios/homie-arcade` to
339
+ `/tmp/homie-arcade-045-0Vaqlp/studio`, packed and installed 0.45.0 there.
340
+ `npm run build` passed for all five existing netplay games.
341
+ `npm run check -- --url http://127.0.0.1:8898` passed for its default game,
342
+ 2048 Race: desktop and phone finished round 1 in 123 seconds, with both
343
+ connections uninterrupted. The original studio was not changed; nothing was
344
+ deployed. The released 0.32.1 Ember Vale gate also passed with an older Worker
345
+ entry: one host and one replica finished round 1 in 92 seconds.
@@ -207,6 +207,7 @@ State belongs only in `fields`, `motion`, or `shared`; never a changing module v
207
207
  | `f.fix()` | Multiples of 1/4096, ±2147483648; `fuel: f.fix({ init: 1 })` |
208
208
  | `f.bit()` | Boolean; `alive: f.bit({ init: true })` |
209
209
  | `f.press()` | Input-only one-shot boolean button; `fire: f.press()` |
210
+ | `f.held()` | Input-only number averaged over the tick (0.46.3); `ax: f.held()` |
210
211
  | `f.tick()` | Absolute tick, 0–4294967295; `until: f.tick()` |
211
212
  | `f.ticks()` | Tick duration, same range; `cooldown: f.ticks()` |
212
213
  | `f.vec3()` | Float32 vector `{x,y,z}`; `target: f.vec3()` |
@@ -220,7 +221,7 @@ State belongs only in `fields`, `motion`, or `shared`; never a changing module v
220
221
  Vector writes may omit z (it becomes zero); reads contain all three coordinates.
221
222
  Integer writes round and clamp, not wrap. Guard against underflow explicitly:
222
223
  `self.hp = Math.max(0, self.hp - damage)`. Inputs may contain only integer types,
223
- fix, bit and press. Each field may hold at most 16,384 cells and a kind 65,536.
224
+ fix, held, bit and press. Each field may hold at most 16,384 cells and a kind 65,536.
224
225
  A scalar is one cell; a vector four; a text is `1 + ceil(max/64)`;
225
226
  a list is `1 + max * itemCells`; a map is `1 + max * (1 + itemCells)`;
226
227
  a struct is one plus the cells of its fields. Map keys are at most 32 characters.
@@ -344,6 +345,7 @@ These costs bound work, not elapsed wall time.
344
345
  | `sendArea(area,event,data?)` | Entity/room; 20 + 2N + 6C; 64 areas/tick, reach 64m | `world.sendArea({sphere:{at:self.pos,r:2}}, 'hit', {damage:1})` |
345
346
  | `after(ticks,event,data?)` | Entity/room; 16 + 6C; wait clamped 1–2147483647; targets caller | `world.after(20, 'ready', {})` |
346
347
  | `emit(effect,at,data?)` | Declared effect; 10 + 6C; at is position or ID; 256 effects a tick, and the next one throws | `world.emit('spark', self.pos, {})` |
348
+ | `emit(effect,at,data,audience)` | The same, plus 8, for who may perceive it (0.46.3): `{within: metres}`, `{to: id}` or `{to: [ids]}` (32 at most), or both | `world.emit('grunt', self.id, {}, {within: 45})` |
347
349
  | `spawn(kind,at,fields?)` | Entity/room; 10 + copy cost; 2048 entities, non-player kinds only | `world.spawn('coin', self.pos, { value: 2 })` |
348
350
  | `despawn(self)` | Own non-player entity; constant guard cost | `world.despawn(self)` |
349
351
  | `place(self,at,options?)` | Own entity; 82; options vel/heading; clamps to bounds, changes movement revision | `world.place(self, world.map.spots('start')[0])` |
@@ -369,7 +371,8 @@ Queries return readonly views; send events to make recipients change themselves.
369
371
  Math vectors are readonly too: replace `d.x += n` with
370
372
  `d = { ...d, x: d.x + n }`, or use `world.math.add`/`scale`.
371
373
 
372
- World properties: `tick`, `dt`, `tune`, `shared`, `map`, `math`, `stage`, `level`,
374
+ World properties: `tick`, `dt`, `unitsLeft` (0.46.3: budget units the running
375
+ handler may still spend, so a planner can stop early), `tune`, `shared`, `map`, `math`, `stage`, `level`,
373
376
  `levelMax`, `round: {n,phase,endsAt}`. Phase is `live` or `over`.
374
377
  `rounds.seconds: 0` means no automatic deadline: your room must call round.end.
375
378
  A positive duration ends automatically. Round results arrive two ticks after the
@@ -780,8 +783,17 @@ Rules, collision, saves and shared state stay authoritative and unchanged.
780
783
  Views must interpolate distant motion and dispose/recreate entities on view exit
781
784
  and entry. Omit the settings for full precision and the room's normal tick rate.
782
785
 
783
- A small room embeds delivery in its Table. Larger rooms open Gate connections;
784
- more than eight Gates use concentrators. The studio template supplies their exports, bindings and
786
+ A small room embeds delivery in its Table. Larger rooms open Gate connections, one channel
787
+ from each Gate to the room: the room runs the rules and hands each Gate one world a tick, and
788
+ the Gate selects, encodes and paces each player's view. A player on a slow connection is sent
789
+ fewer, fresher frames, never a growing backlog; draw from the newest snapshot and interpolate.
790
+ A body that keeps its course is not sent every tick: the view library moves it along its last
791
+ velocity, and it is sent again when that has drifted past `view.precisionM` (near bodies) or half
792
+ the distance it covers between `view.farHz` refreshes (far ones). `precisionM` is what a crowd
793
+ costs a player's connection: 0.01 is a centimetre; 0.05 sends about a sixth fewer bytes.
794
+ Homie sets no pace on what a player sends a server-hosted game. To set your own, add
795
+ `"rates": { "in": 60 }` to `room` in game.json: messages a second per player, by frame kind.
796
+ The studio template supplies the Gate exports, bindings and
785
797
  migration; upgrade the template before deploying an existing studio. No infrastructure naming or studio admission ceiling is required.
786
798
  Read the local measurements and their limitations in
787
799
  `docs/rooms-milestone-2-notes.md` before making a capacity claim.
@@ -932,7 +944,22 @@ Declare `room.historySeconds: 1` (maximum two seconds). The server retains and
932
944
  saves non-collider body positions and placement revisions on its virtual clock.
933
945
  The view's `room.viewTick` is the tick used to interpolate remote bodies. Send it
934
946
  with an action, then use `world.ray(..., {atTick, ...filters})`. Fractional ticks
935
- interpolate saved poses. Player requests clamp to the smaller of the configured window and the server-measured round-trip latency plus 150 ms presentation delay and one simulation tick. Without a measurement only that small margin is allowed. The relay measures a nonce challenge; client-supplied latency figures are ignored.
947
+ interpolate saved poses. A player's request is clamped to: the relay's own
948
+ measurement of that connection's round trip, plus 150 ms of presentation delay,
949
+ plus the whole ticks the input waited between arriving and running (at least one,
950
+ at most a quarter of a second). Without a measurement only that margin is allowed.
951
+
952
+ The measurement (0.46.3) is a low percentile of the last ten seconds of round
953
+ trips timed on the input frames themselves: each frame names the newest snapshot
954
+ its browser had, and the relay knows when that snapshot left. A client cannot
955
+ make it shorter than its real latency, and holding back a separate answer no
956
+ longer raises it. A modified client can still pretend to be further away than it
957
+ is, as it could by really being so; what that buys is bounded by the game's cap.
958
+ Set `room.rewindSeconds` (from 0 to `historySeconds`): the most any shot is
959
+ rewound, whatever the connection reports. `0.25` suits a shooter: a round trip
960
+ up to about 100 ms is compensated in full and a slower player leads the target a
961
+ little. Without it the cap is `historySeconds`, as before. Client-supplied
962
+ latency figures are ignored.
936
963
  A placement or new round prevents rewinding through a teleport or reset.
937
964
 
938
965
  Rewind changes actor poses only. Eligibility filters use current fields, so a
@@ -977,6 +1004,89 @@ skips the intermission. Use it for an authorized retry; validate eligibility in
977
1004
  your room handler. Pending restart intent survives save/restore.
978
1005
 
979
1006
 
1007
+ ### Held input: a tap moves for the time it was down (0.46.3)
1008
+
1009
+ An ordinary input field is read once a tick, at the tick's edge. A key held for
1010
+ 60 ms of a 50 ms tick therefore moves the body for one tick or for two, by where
1011
+ the edges happened to fall: short taps travel too far and vary. Declare a
1012
+ movement axis `ax: f.held()` instead. The browser then sends the average of what
1013
+ the control read over each tick (a key down for a third of the tick reads a
1014
+ third of its value; an analogue stick reads its mean), prediction uses the same
1015
+ number, and `move` multiplies speed by it as before, so the body moves for the
1016
+ part of the tick the key was down. `room.input({ax: 1})` is called as usual,
1017
+ every frame or on change; a boolean counts as 1. `think` returns a plain number.
1018
+ Use it for movement axes, not for buttons: a button is `f.press()` or `f.pulse()`.
1019
+ Keep its values in -1 to 1. An entry that reaches the server after its tick is
1020
+ not counted twice: the tick it missed ran on the value before it, and the server
1021
+ settles the difference on the next ticks, so the body ends where the browser
1022
+ predicted.
1023
+
1024
+ ### Effects with an audience: what a player may perceive (0.46.3)
1025
+
1026
+ Everything in a snapshot's public fields and every plain `world.emit` reaches
1027
+ every player. A sound or a flash must not be drawn from state a rival should not
1028
+ have (another player's exact health), and need not be sent to someone who could
1029
+ not perceive it. Emit the event instead, and say who perceives it:
1030
+
1031
+ ```ts
1032
+ world.emit('grunt', self.id, {}, { within: 45 }); // earshot of where self stands
1033
+ world.emit('hitConfirm', target.id, { damage }, { to: shooter.id }); // the shooter alone
1034
+ world.emit('alarm', self.pos, {}, { within: 30, to: allies }); // either is enough
1035
+ ```
1036
+
1037
+ `within` is metres from the effect's place (the named entity's position at that
1038
+ moment) to the listener's own body; `to` names entities, and the people driving
1039
+ them receive it. A watcher, and a seat with no body yet, perceive the room: they
1040
+ receive every effect with a `within`, and never one addressed only `to` somebody.
1041
+ An effect with no audience is public, exactly as before. Delivery costs one
1042
+ frame a listener on ticks that have such effects and nothing on ticks that do
1043
+ not. Send what is perceivable (a hit landed, roughly how hard); send exact
1044
+ numbers only `to` the player entitled to them.
1045
+
1046
+ ### Predicting the player's own actions (0.46.3)
1047
+
1048
+ The own position is predicted by `move`. Other own actions (a weapon swap, a
1049
+ build, a reload, a hit marker) otherwise wait for the server's round trip. Give
1050
+ the view a predictor for the kind:
1051
+
1052
+ ```ts
1053
+ const room = openRoom<typeof rules>({ predict: { runner(me, input, ctx) {
1054
+ if (input.select) swap(me, input.select, ctx.tick * ctx.dt); // the rules' own pure helper
1055
+ if (input.build && me.builds > 0) { me.builds--; ctx.ghost('cover', spot(me), { size }); }
1056
+ if (input.fire && aimHits(ctx, me)) ctx.effect('hit', me.id, {});
1057
+ } } });
1058
+ ```
1059
+
1060
+ The room runs it for the own body once a predicted tick, after `move`, with that
1061
+ tick's input, and on a copy between ticks, so a press answers on the next drawn
1062
+ frame. `me` holds the body's declared fields (and readonly `id`, `pos`, `vel`,
1063
+ `heading`, `grounded`, `motion`); what it writes is held to the declared types
1064
+ and read back through `room.me`. Every snapshot puts the server's values back
1065
+ and runs the still-pending inputs again, exactly as it does for the position, so
1066
+ a wrong guess corrects itself. Share pure helpers with the rules (no `world`):
1067
+ the server's handler remains the authority and the only place a result counts.
1068
+
1069
+ `ctx.ghost(kind, at, fields?, {match?, seconds?})` draws a provisional entity:
1070
+ `room.each` yields it with `provisional: true` until an entity of that kind that
1071
+ `match(entity)` accepts (default: within a metre) arrives, and then until that
1072
+ one is drawn, so there is never a gap or a double. `ctx.effect(name, at, data?,
1073
+ {match?})` fires the effect's listeners now with `predicted: true`; the server's
1074
+ own effect of that name then arrives with `confirms: true` (use its exact data,
1075
+ do not replay the sound). If the server ran the tick without confirming, the
1076
+ room emits `retract` (`{ghost, kind}` or `{effect, ...data}`) and the ghost
1077
+ leaves: take the marker down quietly. `ctx.ray/rays/rayAll` query what the
1078
+ player sees (`ctx.viewTick`); `ctx.preview` says the run is between ticks. A
1079
+ predictor runs often: keep it cheap, and keep per-entity state in `me`.
1080
+
1081
+ A tick's prediction runs more than once (between ticks, again when the input
1082
+ changes, then the tick itself) and shows each thing once. Things are told apart
1083
+ by their name and `key`: give two effects of one name that mean different things
1084
+ (`ctx.effect('audio', me.id, {name: 'jump'}, {key: 'jump'})` beside a `reload`)
1085
+ a `key` each, and the same for two ghosts of a kind. Read `me.id` into a constant
1086
+ before using it in a `match` closure: `me` is reused. A valued pulse
1087
+ (`f.pulse()`) carries the tick it was predicted on, and the server acts on it in
1088
+ that tick, so a predicted swap is in the snapshot of the tick it was drawn for.
1089
+
980
1090
  ### Discrete actions with a value
981
1091
 
982
1092
  Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call