@genex-ai/multiplayer 0.17.6 → 0.17.9

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 (40) hide show
  1. package/PROTOCOL.md +102 -31
  2. package/README.md +35 -14
  3. package/WORLD-GLTF.md +109 -0
  4. package/WORLD-SCENE.md +135 -0
  5. package/dist/authority/fast-udp.d.ts +4 -2
  6. package/dist/authority/fast-udp.js +8 -46
  7. package/dist/authority/fast-webtransport.d.ts +26 -4
  8. package/dist/authority/fast-webtransport.js +12 -115
  9. package/dist/authority/index.d.ts +379 -41
  10. package/dist/authority/index.js +247 -47
  11. package/dist/authority/module.d.ts +2 -1
  12. package/dist/authority/module.js +1 -1
  13. package/dist/bridge-authority.d.ts +3 -2
  14. package/dist/bridge-authority.global.js +10 -9
  15. package/dist/bridge-authority.js +11 -6
  16. package/dist/bridge.global.js +5 -5
  17. package/dist/bridge.js +3 -3
  18. package/dist/chunk-2NGXXQDS.js +1916 -0
  19. package/dist/{chunk-7QVOIHJF.js → chunk-DJ2XYETS.js} +2 -2
  20. package/dist/{chunk-Y6XHXDIK.js → chunk-MQYARUGU.js} +864 -539
  21. package/dist/{chunk-K3SF5VDS.js → chunk-OD6NRSX2.js} +3 -1
  22. package/dist/{chunk-UX3F6YTV.js → chunk-TEHGMFAG.js} +1 -1
  23. package/dist/chunk-TSYEWPVO.js +44 -0
  24. package/dist/chunk-TVTVIMIH.js +675 -0
  25. package/dist/{chunk-ZSWF4VCN.js → chunk-WEMRRIHH.js} +80 -2
  26. package/dist/chunk-YWQ3ALYY.js +69 -0
  27. package/dist/{chunk-5JGRFT2N.js → chunk-ZDGSUR6E.js} +879 -1898
  28. package/dist/{fast-client-CZ41v-oB.d.ts → fast-client--A88q0kk.d.ts} +85 -4
  29. package/dist/index.js +3 -3
  30. package/dist/level-build-CbRqrYJq.d.ts +140 -0
  31. package/dist/{module-DqtFb5wA.d.ts → module-CSpXrzFc.d.ts} +151 -400
  32. package/dist/{physics-predict-49udN3a1.d.ts → physics-predict-C7ZB1gDG.d.ts} +18 -3
  33. package/dist/protocol.d.ts +90 -2
  34. package/dist/protocol.js +17 -1
  35. package/dist/shot-world-D22tqOUT.d.ts +485 -0
  36. package/dist/world-fit/index.d.ts +549 -0
  37. package/dist/world-fit/index.js +4763 -0
  38. package/dist/world-fit/three.d.ts +77 -0
  39. package/dist/world-fit/three.js +140 -0
  40. package/package.json +15 -3
package/PROTOCOL.md CHANGED
@@ -504,14 +504,19 @@ An engine that uses Colyseus `Predict` for posed players attaches `x y z vx vy v
504
504
  | 1000 | Normal close | Terminal |
505
505
  | 4000 | Consented leave. Also: a requeued match player that did not leave, a server drain after `server:restart`, and duplicate-queue convergence | Terminal. A queue client searches again. |
506
506
  | 4001 | Server shutdown | Terminal |
507
- | 4002 | Protocol error, or the 1200 msg/s ceiling | Terminal; fix the client |
507
+ | 4002 | Protocol error, or the socket's message ceiling (relay 1200/s; authority 2400/s, reason `message_flood`) | Terminal; fix the client |
508
508
  | 4003 | All reconnection attempts failed | Terminal |
509
509
  | 4401 | Session revoked | Terminal; do not rejoin with this identity |
510
+ | 4408 | Authority rooms: the seat was idle past the room's limit (§17.6 *Room lifetime*) | Terminal; a player who comes back joins again |
510
511
  | 4409 | Superseded by a newer session of the same player | Terminal; do not rejoin automatically |
511
512
  | 4500 | Authority rooms: the room was halted (kill switch, takedown, disabled game) | Terminal |
512
- | 4501 | Authority rooms: the room module faulted | Terminal |
513
+ | 4501 | Authority rooms: the room module faulted, or sent too much (`module_bandwidth`, *The downlink budget*) | Terminal |
513
514
 
514
- "Terminal" means the seat is gone and a reconnect would be refused. A client emits `disconnect(code)` and stops.
515
+ "Terminal" means the seat is gone and a reconnect would be refused.
516
+
517
+ *The downlink budget.* A room counts every byte it hands its seats, on their WebSockets and their fast channels, and reports the running total in each usage report (`downlinkBytesTotal`; recorded per lease, not billed). A fast seat may average `SEAT_DOWNLINK_BYTES_PER_S` (256 KiB/s) or its share of a quarter of the room's ceiling, whichever is less: past it its snapshots keep their pace and are cut (§17.12 *Partial snapshots*), its own seat and entity always going. A WebSocket stream cannot be cut, since one skipped patch desyncs it, so a room that sends more than `ROOM_DOWNLINK_BYTES_PER_S` (8 MiB/s) over a whole `ROOM_DOWNLINK_WINDOW_S` (10 s) ends with 4501 and the reason text `module_bandwidth`, which its module fixes by writing less state and fewer events a tick. A burst is no fault. The close report says `module_budget` until the API learns the code. The share is a real limit on large rooms: 68 KiB/s a seat at 30 seats, 32 at 64, where a busy room's seat was measured at 57. A room past about 30 seats (`DOWNLINK_MEASURED_SEATS`) may be cut when full even if honest, no room that large has been measured on UDP, and `validate` warns (`downlink_share`).
518
+
519
+ *The socket ceiling.* On an authority worker a socket carries input frames and `__fast:off`, so it is held to `AUTHORITY_MAX_MESSAGES_PER_SECOND` (2400, in Colyseus's tumbling second: twenty seconds of inputs at 120 Hz, so an honest seat handing over the backlog of a TCP stall is never closed) and frames of at most `AUTHORITY_MAX_FRAME_BYTES` (4 KiB; a larger one closes the socket, uWS 1009). A socket past the ceiling is closed 4002 with the reason text `message_flood`; it gets no reconnection grace, its fast binding closes with it, and the room's other seats notice nothing. A client emits `disconnect(code)` and stops.
515
520
 
516
521
  ## 12. Chat
517
522
 
@@ -627,13 +632,13 @@ The TypeScript client session is `connectAuthority` (`src/authority/session.ts`)
627
632
 
628
633
  - An authority client sends `protocol: 4` (`AUTHORITY_PROTOCOL_VERSION`). Authority rooms accept `AUTHORITY_WINDOW` = `[4, 4]`; anything else is refused with **426** before a room is found or created. A relay room refuses 4 the same way (§2), so neither kind of client can land in the other kind of room.
629
634
  - Authority rooms are served by their own cluster, never by a relay. The embed layer supplies its URLs next to the relay URLs (`colyseus.authorityUrls`; in local test mode, a loopback `genex_authority_url` on a loopback page, with a fixed local credential in place of a token), and the same cluster by region (`colyseus.authorityRegions`, below).
630
- - *Regions.* `colyseus.authorityRegions` is a list of `{ region, urls[] }`; each region is its own matchmaking pool, because the server filters rooms by the region a join entered through (the server-owned `region` option, §17.2). A client reads it leniently: an entry without a non-empty name or without a non-empty url string is dropped, and a repeated name keeps its first entry. When no entry is usable, every `authorityUrls` entry is a region of its own (an older stand, local test mode). With one region the client joins through its first url. With several, it MUST probe as §3.1 does, a preference and never a gate: 3 samples per region, sample n going to the region's url n modulo its url count; a sample is failed when it has not answered after 4000 ms, which is also the ceiling of the whole probe, and once any sample has answered the others get 1000 ms more. A url's latency is its best answered sample (a finite number of ms, at least 0). Regions are tried in this order: those with an answer, nearest first (a tie keeps list order), each entered through its best url (the first on a tie); then the silent ones in list order, through their first url. A join refused 503 twice in a row on one region moves to the next region at once; any other 503 waits the capacity schedule's next step on the same region, and the join gives up with the last refusal when the schedule is spent. An explicit `url` (an invite: the inviter's session url) skips the probe, so a party plays in its leader's region. `netcode-bench/conformance/authority/authority-regions.json` pins every rule; the TypeScript, Unreal, Unity and Rust clients pass it exactly. The Godot client joins its first url (labelled in CI).
631
- - A client joins with `joinOrCreate('authority', { gameId, embedToken, name, protocol: 4 })`. The token rules of §3.2 apply. A preview (staging) token joins the game's staging module, in rooms apart from the live game's (§3.4). A join lands in a room running the module the game currently ships: once a newer module has a room, new joins go there, while rooms on the older one keep their players until they empty.
635
+ - *Regions.* `colyseus.authorityRegions` is a list of `{ region, urls[] }`; each region is its own matchmaking pool, because the server filters rooms by the region a join entered through (the server-owned `region` option, §17.2). A client reads it leniently: an entry without a non-empty name or without a non-empty url string is dropped, and a repeated name keeps its first entry. When no entry is usable, every `authorityUrls` entry is a region of its own (an older stand, local test mode). With one region the client joins through its first url. With several, it MUST probe as §3.1 does, a preference and never a gate: 3 samples per region, sample n going to the region's url n modulo its url count; a sample is failed when it has not answered after 4000 ms, which is also the ceiling of the whole probe, and once any sample has answered the others get 1000 ms more. A url's latency is its best answered sample (a finite number of ms, at least 0). Regions are tried in this order: those with an answer, nearest first (a tie keeps list order), each entered through its best url (the first on a tie); then the silent ones in list order, through their first url. A dead path moves to the next region at once: no answer at all, a gateway's 502 or 504, or Cloudflare's 520 to 526 and 530. Anything but those and 503 (with 4213, a process shutting down, which a sibling answers on the next try) is the server's answer and final. A join refused 503 twice in a row on one region moves to the next region at once; any other 503 waits the capacity schedule's next step on the same region, and the join gives up with the last refusal when the schedule is spent. An explicit `url` (an invite: the inviter's session url) skips the probe, so a party plays in its leader's region. `netcode-bench/conformance/authority/authority-regions.json` pins every rule; the TypeScript, Unreal, Unity and Rust clients pass it exactly. The Godot client joins its first url (labelled in CI).
636
+ - A client joins with `joinOrCreate('authority', { gameId, embedToken, name, protocol: 4 })`, or names a pool or a private room (§17.2 *Choosing a room*). The token rules of §3.2 apply. A preview (staging) token joins the game's staging module, in rooms apart from the live game's (§3.4). A join lands in a room running the module the game currently ships: once a newer module has a room, new joins go there, while rooms on the older one keep their players until they empty.
632
637
  - Appending a schema field, adding a message or adding an optional key is not a bump; the §2 bump rules apply to this section with `AUTHORITY_PROTOCOL_VERSION` in place of `PROTOCOL_VERSION`.
633
638
 
634
639
  ### 17.2 Join options and refusals
635
640
 
636
- The options are §3.4's. `AUTHORITY_SERVER_OWNED_JOIN_OPTIONS` are server-owned and refused with 400: the relay's five plus `moduleSha`, `tickHz`, `maxPlayers`, `lagComp`, `seed`, `units` and `leaseId`, which the server sets from the room's admission, and `region`, which it sets to the region the join entered through (§17.1 *Regions*). The relay rooms' list does not change. A physics room (§17.10) also refuses, with 426, a join whose `caps` option lacks `physics@1`.
641
+ The options are §3.4's. `AUTHORITY_SERVER_OWNED_JOIN_OPTIONS` are server-owned and refused with 400: the relay's five plus `moduleSha`, `tickHz`, `maxPlayers`, `lagComp`, `seed`, `units` and `leaseId`, which the server sets from the room's admission, `region`, which it sets to the region the join entered through (§17.1 *Regions*), and `createdBy`, the hashed opener it stamps at the gate (*Room creates*, below). The relay rooms' list does not change. A physics room (§17.10) also refuses, with 426, a join whose `caps` option lacks `physics@1`.
637
642
 
638
643
  A new authority room is admitted by the platform before it is created. Admission fails closed: when it cannot be checked the room is refused, never run unmetered. A refusal never closes a room that is already live.
639
644
 
@@ -644,6 +649,21 @@ A new authority room is admitted by the platform before it is created. Admission
644
649
  | 424 | The game has no published room module on this channel, or it could not be loaded. | Terminal until the game is republished |
645
650
  | 429 | The owner's room quota, concurrency cap or credits refused a new room. | MAY retry later |
646
651
  | 503 | No authority server can take the room, or admission could not be checked. | MAY retry with backoff |
652
+ | 421 | A join by code entered through another region than the room's (*Choosing a room*, below). | Try the next region |
653
+
654
+ **Choosing a room.** Two optional join options let a game choose its room. They are optional keys, so by §17.1's rule neither is a protocol bump, and a client that sends neither joins exactly as it did: the same options on the wire, the same one pool per game and region, and nothing new in the state.
655
+
656
+ - `matchKey` (string): the pool to join. It is 1 to 32 characters of `a-z 0-9 . _ : -`, the first a letter or a digit (`AuthorityRoomLimits`, `src/authority/room-key.ts`). Anything else, a value of any other type included, is refused with **400** before the token is looked at and before any room exists. Absent and `''` are the same: no key. Rooms are found by game, module, region and key, and the server writes `''` into the filter when no key was sent, so a join without a key never lands in a keyed room. The key narrows the pool the server already chose and replaces none of it: the game and its channel come from the verified token and the region from the worker, so no key reaches another game, another region, or the staging module from live. A key is the client's choice, so a module MUST treat one it does not know as its default and never as an error.
657
+ - `private` (boolean): `create('authority', { …, private: true })` makes a private room; any other type is refused with **400**. A private room is unlisted from before its first await, so `joinOrCreate` and `join` never reach it, whatever key they send, and a `joinOrCreate` that carries `private` may land in a public room. The room's code is its `roomId`, returned by `create`. A private room takes a `matchKey` like any other.
658
+ - *Join by code.* `joinById(code, { … })` reaches that room and no other. A code is 1 to 32 characters of `A-Z a-z 0-9 _ -`, and a client refuses any other before sending it, because a code goes into a url. The room still refuses a seat for another game or channel (403) and a join that entered through another region (**421**), neither taking a seat. A `matchKey` sent with a code is checked (400) and then has no effect: the room's key is its own. A client MUST NOT send one, and an SDK refuses the pair. A client walks its regions in §17.1's order: on 421 and on Colyseus's 522 (no such room here, or it is full or closing) it tries the next region, and any other refusal, or either on the last region, is final. An explicit `url` skips the walk. A room stays on the module it started with, as §17.1 says of any live room.
659
+ - *Joining together.* Players who `joinOrCreate` one pool at once share one room, up to its seats. While a pool's room is being created, a later join's request stays open until that room is listed (at most about 30 s, normally the room's create time and under a second more) and is then seated in it; a client sees a slower answer and nothing else. No client sends anything new for this. It holds per pool, and each region is its own pool (§17.1 *Regions*): players whose probes pick different regions do not meet in the default pool. An invite's `url` or a room code brings them together.
660
+ - *Reading it.* `AuthorityRoomState.matchKey` (§17.3) is the room's key, absent for none: this is how a friend who joined by code learns it. A module reads `ctx.matchKey` in `init` (`''` for none) to choose a mode.
661
+
662
+ **Room creates.** A `create` with `private: true`, or a `matchKey` no room has, always makes a room, and a guest token is free, so an authority worker holds who may open how many, per game, before it asks admission (`apps/colyseus/src/authority/create-limits.ts`). Per `CREATE_WINDOW_S` (10 s), and at once among rooms opened at will (private or keyed) that are still running: a signed-in identity 3, the guests on one address 8, the signed-in players on one address 16. Past it the create is refused **429** with the message naming which, the other limits of *Refusals* unchanged, and no API call is made. An address is `CF-Connecting-IP` (an IPv6 address's /64), kept only hashed per game. Creates of the default pool, made only when every room of the pool is full, have the rate and no such cap. Joins are never counted, and the limits are per person and per address, never per game, so a game's many players do not meet them. A read that fails counts nothing. A worker that the API just refused a game's quota answers that game's next creates the same for `ADMIT_QUOTA_HOLD_MS` (2 s), without asking again.
663
+
664
+ **Revocation.** A signed-in seat's session is checked at join (§3.4) and again by the room every `RelayTiming.REVOCATION_SWEEP_MS` (30 s), a batched read per room where the process holds the internal secret, as a relay room does, and on a fleet node, which never holds it (DEPLOY.md §8e), one read per signed-in seat, where a refused read counts as not revoked until a later sweep. A revoked seat is closed with 4401 and gets no reconnection grace: its fast binding closes at that moment, so its datagrams are refused from then on (`unknown_sid`), a seat inside its grace is removed too, and the module hears `onLeave` with `revoked`. A guest has no session and costs no read. A read that fails removes nobody (a miss is bounded by the token's own 15 minutes), and an answer about a session the room did not ask about means nothing.
665
+
666
+ `conformance/authority/room-key.json` pins every rule above that a client applies: the key and code tables, what each way into a room sends, the walk, and which world a key picks (§17.7 *World*). The TypeScript, Unreal, Unity and Rust clients pass it; Godot's session joins the default pool and is a labelled skip (its schema decodes `matchKey`). The server's hostile tables are `packages/authority-host/test/join-gate.test.ts` and `room-key.test.ts`.
647
667
 
648
668
  ### 17.3 State and input
649
669
 
@@ -651,6 +671,8 @@ The server alone writes the state; field order is wire order and fields are only
651
671
 
652
672
  **`AuthorityRoomState`**: `players` map<string, AuthorityPlayer> · `entities` map<string, AuthorityEntity> · `tick` number (the fixed-step tick last written) · `clockOrigin` float64 (as §16.1) · `preset` string (`{"name":…,"params":{…}}` JSON text, `''` = none) · `moduleSha` string (hex sha256 of the running module) · `tickOrigin` float64 (the room clock at tick 0's nominal instant, §17.9) · `physics` string · `lengthUnit` float32 · `bodies` map<string, AuthorityBody> (the physics hash, the world's length unit and the body descriptors, §17.10).
653
673
 
674
+ `AuthorityRoomState` appends `matchKey` string, optional: the room's match key, written once at creation and absent for none (§17.2 *Choosing a room*).
675
+
654
676
  **`AuthorityPlayer`**: `name` string · `uid` string (§3.3) · `team` string (`''` = none) · `connected` boolean · `avatarUrl` string · `seqBase` number (the inputs the server consumed from the seat's earlier sockets, where this socket's client sequence counts on from; 0 until a reconnect, §17.9).
655
677
 
656
678
  **`AuthorityEntity`**, keyed by entity id (at most 64 characters): `kind` string · `owner` string (seat session id or `''`; a client predicts the entity it owns) · `x` `y` `z` `vx` `vy` `vz` `yaw` float32 · `hp` number · `flags` number (integer below 2^24) · `epoch` number (a change means snap, never interpolate) · `data` string (JSON text, at most 256 characters, `''` = none) · `t0` number · `g` float32 · `src` string (a projectile's launch tick, gravity and shooter, §17.9; `t0` = 0 is not a projectile) · `pitch` `duck` float32 · `mstate` number · `cap` float32 (default 1; a `firstperson` body's aim pitch, duck amount, movement bits and speed multiplier, §17.7) · `spin` `fuel` float32 (a `vehicle` body's yaw rate and boost fuel, §17.7) · `qx` `qy` `qz` `qw` `lvx` `lvy` `lvz` `avx` `avy` `avz` (16-bit quantized) · `sleep` boolean (a physics body's state, written by the host only and without a default, so a room without physics never sends them, §17.10). Entity floats are plain float32, rounded by the server before writing, and stay unquantized until every engine decoder passes conformance with quantized fields; §17.10's quantized body fields reach only clients that claim `physics@1`.
@@ -659,9 +681,11 @@ The server alone writes the state; field order is wire order and fields are only
659
681
 
660
682
  A client sends one input per fixed step while its game is playing, and nothing otherwise: an authority room carries no §16 heartbeat, because there `room.input()` is the game's own input channel and a heartbeat would be simulated as blank input. The game's inputs drive the room clock instead. Reconnection is §8's, with the same settings; no input is queued across a drop, and after the reconnect's first state the client re-seeds its prediction from the server's state, so nothing sent before the drop is replayed. A client predicts its own entity, the first entity it owns that is not a projectile (`t0` = 0) and, in a physics room, not a body entity (§17.10), with the room's movement preset (§17.7; `firstperson` and `vehicle` only while the client's world matches the room's). "First" is in the state's `entities` order, which the server and the sandbox share: an id that one output both removes and writes keeps its place there, and any other new id goes last. When that entity's `epoch` changes (a respawn or teleport), the client snaps it to the server's new pose and replays on top of it every input after the ack that came with the bump: the server applies the inputs that were in flight to the new life, so dropping them would leave the prediction a round trip behind and correct it tick by tick. The snap is not counted as a correction. It renders every other entity but a projectile (§17.9) interpolated `AuthorityTiming.RENDER_DELAY_MS` behind, snapping instead when its `epoch` changes. The snap is on that same render timeline: until the render time reaches the patch that carried the new epoch, the client draws the old life and holds the pose of the patch before it, and §17.5's rewind steps a respawn the same way, so a shot is judged where it was aimed.
661
683
 
684
+ **Correction telemetry.** A client measures each reconcile of its own entity across the reconcile, per mirrored field. Its size is the length of the move of `x y z`, in room units; a move past 0.05 metres (× the room's units per metre: `lengthUnit`, else the preset's implied 100 for `vehicle` and 39.37 for `firstperson`, else 1) is a visible correction. Every other mirrored field but `mstate`, `cap`, `spin` and `fuel` is measured apart, each in its own unit (a heading by the short arc across ±π, a pitch by the plain difference): the largest is `peakStateCorrection`, which says only whether the prediction was exact. A velocity or an angle is never a distance. No client decides anything by these. `conformance/authority/correction.json` holds every port to it.
685
+
662
686
  ### 17.4 Room modules and the sandbox wire
663
687
 
664
- A room module is one ES module whose default export is `defineRoom({ contract: 1, config?, world?, init, onJoin, onLeave, onInput, step })` (`src/authority/module.ts`; `world` is the collision world of `firstperson` and `vehicle`, §17.7). It runs in a sandbox with no clock, timers, network, filesystem, `require` or shared memory; `ctx.random()` is seeded per room, so a replay of the same inputs is exact. `config` may ask for `tickHz` 20, 30 or 60 (or 120 with `physics`, §17.11), `maxPlayers`, a movement `preset` (§17.7), a lag-compensation button mask and `physics` (§17.10); the server clamps each to the room's admission. A module that asks for no `tickHz` runs at its admission's rate: in production the one its go-live declared (the manifest's `tickHz`, else the report's), and a 120 Hz go-live is refused (`authority_tick_rate_needs_physics`) unless the validated module asks for physics. `genex-authority-dev validate` and the local server admit at `MAX_TICK_HZ` (120) with an `unaskedTickHz` of `DEFAULT_TICK_HZ` (60), so a module's own rate is what its report says and one asking none reports 60.
688
+ A room module is one ES module whose default export is `defineRoom({ contract: 1, config?, world?, init, onJoin, onLeave, onInput, step })` (`src/authority/module.ts`; `world` is the collision world of `firstperson` and `vehicle` and what `ctx.world.ray` asks in any room, §17.7). It runs in a sandbox with no clock, timers, network, filesystem, `require` or shared memory; `ctx.random()` is seeded per room, so a replay of the same inputs is exact. `config` may ask for `tickHz` 20, 30 or 60 (or 120 with `physics`, §17.11), `maxPlayers`, a movement `preset` (§17.7), a lag-compensation button mask and `physics` (§17.10); the server clamps each to the room's admission. A module that asks for no `tickHz` runs at its admission's rate: in production the one its go-live declared (the manifest's `tickHz`, else the report's), and a 120 Hz go-live is refused (`authority_tick_rate_needs_physics`) unless the validated module asks for physics. `genex-authority-dev validate` and the local server admit at `MAX_TICK_HZ` (120) with an `unaskedTickHz` of `DEFAULT_TICK_HZ` (60), so a module's own rate is what its report says and one asking none reports 60.
665
689
 
666
690
  The server and the sandbox exchange JSON text only, copied in and out, with one guest call per tick: `__genex_init(configJson, seed)` returns `InitOut`, and `__genex_tick(tickInJson)` returns `TickOut` (`src/authority/wire.ts`). `TickIn` carries the tick, `dt`, joins, leaves and inputs; each input carries its seat, the host's own per-seat count `seq`, the input's client sequence `cseq` (§17.9) and, for lag compensation, `seen` (§17.5). `TickOut` carries the changed entities only (`up`, an id plus the changed fields), removed ids (`rm`) and game events (`ev`: `type`, optional JSON `data`, optional seat `to`). In a physics room both also carry §17.10's keys, and the host calls a third entry, `__genex_world()`, once at load.
667
691
 
@@ -669,13 +693,19 @@ Guest output is untrusted. `validateTickOut` / `validateInitOut` rebuild it from
669
693
 
670
694
  ### 17.5 Lag compensation
671
695
 
672
- When an input's `buttons` intersect the module's `lagComp.buttons`, the server attaches `seen` to that input: entity id → `[x, y, z]` as that player was rendering it (`rewind.lastSeenBy`), at most `AuthorityTiming.MAX_REWIND_MS` back. While the module handles that input, `ctx.rewind.hitscan` and `ctx.rewind.overlapAt` test against the `seen` positions; otherwise they test the current ones. `ctx.rewind.at(id)` answers that same position as `{ x, y, z }` (an entity absent from `seen` where it stood when the tick began, as the two tests judge it), so a module can judge a shot against something that is not a sphere where the shooter saw it (a closing door or shutter, a moving platform's edge); it answers `null` for an id that does not exist, a projectile (§17.9) or a physics body entity (§17.10). The sandbox never holds a server object: `seen` is plain data inside `TickIn`. In a room with lifts (§17.11 *Lifts*) each seen entity that stands on a lift now (its state against the lifts at the tick the shot is judged in: the tick its input is applied in, within a tick of the shooter's drawing) is moved into that lift's frame from the shooter's render tick to that tick, `P_L(to) ∘ P_L(from)⁻¹ ∘ seen` on x and y, at its height now (TypeScript `authority-host/src/lag-comp.ts` `seenInLiftFrames`): the shooter drew a rider on a moving lift where the lift carries it on its own clock.
696
+ When an input's `buttons` intersect the module's `lagComp.buttons`, the server attaches `seen` to that input: entity id → `[x, y, z]` as that player was rendering it (`rewind.lastSeenBy`), at most `AuthorityTiming.MAX_REWIND_MS` back. While the module handles that input, `ctx.rewind.hitscan` and `ctx.rewind.overlapAt` test against the `seen` positions; otherwise they test the current ones. `ctx.rewind.at(id)` answers that same position as `{ x, y, z }` (an entity absent from `seen` where it stood when the tick began, as the two tests judge it), so a module can judge a shot against something that is not a sphere where the shooter saw it (a closing door or shutter, a moving platform's edge); it answers `null` for an id that does not exist, a projectile (§17.9) or a physics body entity (§17.10). `ctx.rewind.pose(id)` answers `{ x, y, z, yaw, duck }`: `at`'s position with the entity's heading and duck amount at that same instant, live where `at` is live and `null` where `at` is `null`, so a module can place hit zones by stance (a ducking target's head is lower, a turned one's back faces elsewhere). `TickInput.seenPose` carries them: entity id → `[yaw, duck]`, beside `seen` and under its rules (only entities the shooter saw facing or ducking otherwise than they do now, once per seat per tick, whole or not at all) with a per-tick budget of its own, so `seen`, `hitscan`, `overlapAt` and `at` answer exactly as they did without it. An entity absent from `seenPose` reads as it stood when the tick began; an entity that never ducks reads `duck` 0. `yaw` turns along the shortest arc between the two recorded patches around the shot, as clients draw it, and a respawn steps both values with the position (§17.3). `seenPose` and `rewind.pose` are server and sandbox only: nothing about them reaches a client or an input message, and they are an append-only addition under §17.1 (an optional key, no protocol bump). The sandbox never holds a server object: `seen` and `seenPose` are plain data inside `TickIn`. In a room with lifts (§17.11 *Lifts*) each seen entity that stands on a lift now (its state against the lifts at the tick the shot is judged in: the tick its input is applied in, within a tick of the shooter's drawing) is moved into that lift's frame from the shooter's render tick to that tick, `P_L(to) ∘ P_L(from)⁻¹ ∘ seen` on x and y, at its height now, its `seenPose` yaw turned by the lift's turn (TypeScript `authority-host/src/lag-comp.ts` `seenInLiftFrames`): the shooter drew a rider on a moving lift where the lift carries it on its own clock.
697
+
698
+ *The rewind's bound.* A shot's render time is the client's own word, so the room holds it to what the seat's lag can explain. An honest input is older at the server than its stamp's lead: `renderDelay + rtt/2` when it was stamped, plus `rtt/2` on its way up. So the earliest time a shot is judged at is its arrival less the seat's measured round trip, `RENDER_DELAY_MS` and `REWIND_SLACK_MS` (100); a stamp further back is judged at that bound, and `MAX_REWIND_MS` still applies. A fast seat's round trip is the largest reading of the last 5 s, each a snapshot's send time against the first datagram acknowledging it, and its arrival is the datagram's, so an input's wait in the buffer buys no rewind. A WebSocket seat's round trip is its handshake's, and its arrival is taken as now less `REWIND_QUEUE_MS` (100). With no reading yet only `MAX_REWIND_MS` binds. A client can still claim lag by delaying its own acknowledgements, at the cost of its snapshot deltas, up to `MAX_REWIND_MS`: the bound stops a stamp lying alone. A client drawing further behind than `RENDER_DELAY_MS` plus the slack is judged at the bound.
673
699
 
674
700
  ### 17.6 Timing and close codes
675
701
 
702
+ *Room lifetime.* A forgotten tab would keep a seat and an occupied room for good, billed to the game's owner, so every room has two limits, settable in `config.lifetime`, whole seconds inside their bounds (`AuthorityLifetime`), with no "off"; anything else is a bad config at its path. `idleSeatS` (default 1800, 30 to 86400): a seat whose input has not changed for this long leaves, closed 4408 with the reason text `seat_idle`, with no grace, and the module hears `onLeave` with `idle`. Sending the same input every step is idle, as a tab left open does; any change of a field is not. A game whose honest players stand still that long (turn-based, a spectator's seat) raises it. `maxRoomAgeS` (default 86400, 60 to 604800): a room this old is announced with `server:restart` and ended after `drainMs` with 4001 and the reason text `room_age`, like a drain; metering hears `drain`, no fault.
703
+
704
+ *The input meter.* Each input a room feeds steps its seat's preset a whole `dt`, or hands a room without a preset one `onInput`, so the rate a seat is fed at is its speed, and it is the server's to set: in every room, whatever the preset, a tick earns a seat one step (and 0.1% more, `INPUT_DRIFT_PER_TICK`, so a client whose clock runs fast of the server's keeps no standing buffer). What it does not spend waits: up to the per-tick cap of 4 at once, the rest in a bank of up to `INPUT_BUFFER` steps, of which a tick releases at most a quarter. So credit comes only from ticks that took no step, and from ticks the room itself dropped in a hitch: an honest backlog after a stall drains at 1.25 inputs a tick, and a client that sends more than one input a tick gains nothing, its surplus overflowing its own buffer (the oldest input is counted consumed). A repeat (§17.11 *Input pacing*) spends a step, and the absorb that pays it back spends none. A repeating seat's new starve run banks one step, at most one a second, for the extra input its client's throttle then sends, so a client that starves on purpose gains at most a step a second. A client MUST NOT rely on more than this, and an SDK sends one input per fixed step.
705
+
676
706
  `AuthorityTiming` holds the defaults: a 30 Hz fixed step, a 100 ms render delay for other entities, a 500 ms rewind window, a 32-input buffer per seat, a 50 ms state patch interval (the value the server-authority spike measured its feel gates at), and at most 100 ms of a starved seat's repeated inputs owed in a predicting room (`INPUT_REPEAT_MAX_MS`, §17.11 *Input pacing*). Every value but the module's own tick rate is a server knob.
677
707
 
678
- Close codes 4500 (halted by the operator or the platform: kill switch, takedown, a disabled game) and 4501 (the module faulted) are terminal, like the other ≥4000 codes of §11. A physics room's failures (§17.10) close with these two.
708
+ Close codes 4500 (halted by the operator or the platform: kill switch, takedown, a disabled game) and 4501 (the module faulted) are terminal, like the other ≥4000 codes of §11 except 4001 and 4003, which a seat comes back from by itself (§17.13). A physics room's failures (§17.10) close with these two. The close frame's reason text names why, one of `AuthorityCloseCode` (`module_timeout`, `module_memory`, `module_error`, `module_output_invalid`, `module_budget`, `halted`, `host_stall`, `physics_budget`, `physics_internal`), when a room's end has a reason (a drain and an emptied room have none). It is for the game's creator: a client reads anything else as no reason (`authorityCloseCodeOf`), and players are told `AUTHORITY_ROOM_ENDED_TEXT`.
679
709
 
680
710
  ### 17.7 Movement presets
681
711
 
@@ -714,6 +744,7 @@ A preset is movement the server simulates and each client's reconciler replays i
714
744
  | `standHeight` | 72 | (0, 10000] | Hull height standing |
715
745
  | `crouchHeight` | 54 | (0, 10000] | Hull height fully ducked |
716
746
  | `duckTime` | 0.4 | (0, 100] | Seconds to duck fully, and to stand back up |
747
+ | `airDuck` | 1 | integer, [0, 1] | 1: a duck can deepen in the air, lifting the feet (a crouch-jump). 0: only on the ground |
717
748
  | `stepHeight` | 18 | [0, 10000] | Highest ledge walked up without a jump |
718
749
  | `standable` | 0.7 | [0, 1] | Least z of a ramp top's unit normal that can be stood on |
719
750
  | `groundTrace` | 2.5 | [0, 10000] | How far below the feet ground is found |
@@ -721,12 +752,17 @@ A preset is movement the server simulates and each client's reconciler replays i
721
752
  | `skin` | 0.03 | [0, 100] | Clearance kept from a surface (Source's DIST_EPSILON) |
722
753
  | `pitchLimit` | 1.5533430576324463 | [0, 1.5707963705062866] | float32(89°), radians |
723
754
  | `jumpBit`, `duckBit`, `walkBit` | 1, 2, 4 | integer, [0, 2^24) | Button masks: any shared bit counts as held; 0 disables |
755
+ | `runBit` | 0 | integer, [0, 2^24) | Button mask of a run the module owns: while not 0 and not held, speed is `jogSpeed`. 0: every move runs |
756
+ | `jogSpeed` | 1 | [0, 1] | Speed multiplier without `runBit` (duck and walk win) |
724
757
  | `substeps` | 2 | integer, [1, 8] | Sub-steps per step |
758
+ | `capZeroJump` | 1 | integer, [0, 1] | 1: a jump works at `cap` 0. 0: a press at `cap` 0 is no jump (a freeze time) |
725
759
  | `world` | 2615243109 | integer, [0, 2^32 − 1] | The collision world's hash, written by the sandbox (below) |
726
760
 
727
761
  *Body.* The step reads and writes `x y z vx vy vz yaw pitch duck mstate` and reads `cap`; the module writes `cap` (a weapon's or a wound's speed) and the step never does. `cap` scales the speed a body accelerates to, never how hard it accelerates (`accelerate()` pushes with `maxSpeed`), so a wound may slow a player to a crawl without leaving it unable to overcome friction. Each is read sanitized: `x` to `vz` missing or non-finite → 0, clamped to [−1e9, 1e9]; `yaw` and `pitch` missing or non-finite → 0; `duck` (0 standing, 1 fully ducked) missing or non-finite → 0, clamped to [0, 1]; `cap` missing or non-finite → 1, clamped to [0, 4]; each of these then float32. `mstate` is an integer in [0, 2^24), else 0, of which only the bits of value 1 (`onGround`), 2 (`ducking`) and 4 (`jumpHeld`) are kept. The game derives its eye height from `duck` (cs2: 64 standing, 46 ducked); it is not part of the step.
728
762
 
729
- *World.* `{ boxes: [[minX, minY, minZ, maxX, maxY, maxZ], …], ramps: [[minX, minY, minZ, maxX, maxY, maxZ, axis, lo, hi], …] }`. A ramp is a box whose top is a plane rising along x (`axis` 0) or y (`axis` 1), at height `lo` at that axis's min end and `hi` at its max end. A world is refused unless it has at most 4096 boxes and 512 ramps, each an array of exactly six (box) or nine (ramp) numbers, every value is a float32 (`float32(v) = v`) of magnitude at most 1e9 (−0 is read as 0), min < max on every axis, and each ramp has `axis` 0 or 1, `minZ ≤ lo`, `minZ ≤ hi` and `maxZ = max(lo, hi)`. Its order is the order given: solids are indexed boxes first (0 … B−1), then ramps (B … B+R−1). The world is fixed for a room's life and never enters the state or crosses the wire.
763
+ *World.* `{ boxes: [[minX, minY, minZ, maxX, maxY, maxZ], …], ramps: [[minX, minY, minZ, maxX, maxY, maxZ, axis, lo, hi], …] }`. A ramp is a box whose top is a plane rising along x (`axis` 0) or y (`axis` 1), at height `lo` at that axis's min end and `hi` at its max end. A world is refused unless it has at most 32,768 boxes and 4,096 ramps, each an array of exactly six (box) or nine (ramp) numbers, every value is a float32 (`float32(v) = v`) of magnitude at most 1e9 (−0 is read as 0), min < max on every axis, and each ramp has `axis` 0 or 1, `minZ ≤ lo`, `minZ ≤ hi` and `maxZ = max(lo, hi)`. Its order is the order given: solids are indexed boxes first (0 … B−1), then ramps (B … B+R−1). The world is fixed for a room's life and never enters the state or crosses the wire.
764
+ - *Several worlds.* A module MAY also export `worlds`, a map of up to 16 worlds by name, each name a match key (§17.2 *Choosing a room*). A room whose key the map names (an own property, so `constructor` names nothing) runs that world; any other room, a key the map does not name included, runs `world`, because a client chose the key and its typo is never the module's fault. A `worlds` that is not a plain object, holds more than 16, or has a name that is not a match key is a bad config in every room, whatever its key. A module with `worlds` MUST leave `params.world` unset, since no one hash fits, and is refused otherwise. Only the room's world is validated and compiled at load, so a room's CPU and the §17.10 world text (`MAX_WORLD_TEXT_CHARS`) are each one world's whatever the map holds; what the map costs is bundle bytes, and all of it must fit the module's 1 MiB (`MODULE_MAX_BYTES`), which holds four or five worlds at the 4096-box and 512-ramp limit as JSON text. `authority validate` loads the module once per name, so a broken world is refused at publish, not when a player first picks it.
765
+ - A client holding the same map picks the same world (`selectMatchWorld(worlds, world, key)`, the same call in each engine SDK) and hands it to `setWorld`. The key to use is the one it asked for or, after a join by code, `AuthorityRoomState.matchKey`. Nothing more is needed for prediction: the rule below already holds it off, and `prediction.reason` stays `world_mismatch`, until the two hashes agree.
730
766
  - The module exports it beside its config, `defineRoom({ contract: 1, config: { preset: { name: 'firstperson', params } }, world, … })`; no `world` is the empty world. At init the sandbox validates it (a refused world is a bad config, §17.4), keeps its own copy, and writes its hash into the resolved preset's `params.world`. A module that sets `params.world` must give that hash, or the config is refused.
731
767
  - The game client hands its SDK the same world (TypeScript `session.setWorld(world)`, the same call in each engine SDK), validated by the same rules; until then its world is the empty world. It predicts its own entity only while its world's hash equals the room's `params.world`, and otherwise renders it from the server's state without prediction, as §17.8 says of an engine without a passing port.
732
768
  - The hash is 32-bit FNV-1a: `hash = 2166136261`, then for each byte `b`, `hash = ((hash XOR b) × 16777619) mod 2^32`. The bytes are the box count and the ramp count, each a little-endian u32, then all the boxes in order, six values each, then all the ramps in order, nine values each (`axis` as 0.0 or 1.0), each value a little-endian IEEE 754 binary32. The empty world hashes to 2615243109 (0x9BE17165).
@@ -740,13 +776,13 @@ One step with input `(moveX, moveY, yaw, pitch, buttons)` and length `dt`:
740
776
  2. `buttons`: an integer in [0, 2^24), else 0. `jump = (buttons AND jumpBit) ≠ 0`; `duckDown` and `walk` likewise with `duckBit` and `walkBit`.
741
777
  3. `dt` as topdown's step 2; `h = dt / substeps`.
742
778
  4. `yaw'` as topdown's step 5: the input `yaw` wrapped, or, when that is not usable, the previous yaw as the body was read (*Body*: float32, never wrapped, so a previous yaw of 4 stays 4). `pitch'` is the input `pitch` wrapped as `yaw'` is, or, when that is not usable, the previous pitch as the body was read (never wrapped); either is then clamped to [−`pitchLimit`, `pitchLimit`] (a float32 already), so a previous pitch of 4 gives `pitchLimit`, not the −`pitchLimit` a wrapped 4 − 2π would.
743
- 5. When `dt > 0`: `pressed = jump and not jumpHeld` (the jump edge, taken once per input), then `jumpHeld = jump`, then run `substeps` sub-steps in turn, each with step length `h`. When `dt = 0`, none, and `jumpHeld` stays as read.
779
+ 5. When `dt > 0`: `pressed = jump and not jumpHeld and (capZeroJump is 1 or cap ≠ 0)` (the jump edge, taken once per input: a press held through a lift from `cap` 0 stays held, and jumps only when pressed again), then `jumpHeld = jump`, then run `substeps` sub-steps in turn, each with step length `h`. When `dt = 0`, none, and `jumpHeld` stays as read.
744
780
  6. Write `x y z vx vy vz duck` as the last sub-step left them (or as read, with −0 as 0), `yaw'`, `pitch'`, and `mstate = onGround + 2 · ducking + 4 · jumpHeld`.
745
781
 
746
782
  One sub-step, with `hull(level) = standHeight + (crouchHeight − standHeight) · level`, `H` the current hull height `hull(duck)` wherever it is used, and `r = radius`. Each procedure's names are its own: a name a procedure sets is not the same value as one another procedure sets.
747
783
  1. `duck()`.
748
784
  2. `was = onGround`; `checkGround()`; when `onGround` and not `was`: `vz = 0`.
749
- 3. `base = maxSpeed · cap`; `target = base · crouchSpeed` when `ducking`, else `base · walkSpeed` when `walk`, else `base`; `wishSpeed = amount · target`.
785
+ 3. `base = maxSpeed · cap`; `target = base · crouchSpeed` when `ducking`, else `base · walkSpeed` when `walk`, else `base · jogSpeed` when `runBit` is not 0 and shares no bit with the input's buttons, else `base`; `wishSpeed = amount · target`.
750
786
  4. When `onGround`: if `pressed`, then `vz = jumpImpulse`, `onGround = false`, `pressed = false` and `airAccelerate()`; otherwise `friction()`, `accelerate()` and `vz = 0`. When not `onGround`: `airAccelerate()`. (A press made while airborne therefore jumps on the first sub-step of its input that starts on the ground, before any friction.)
751
787
  5. `falling = not onGround`; when `falling`: `vz = vz − gravity · h · 0.5`.
752
788
  6. `vx`, `vy`, `vz` each clamped to [−`maxVelocity`, `maxVelocity`].
@@ -755,13 +791,14 @@ One sub-step, with `hull(level) = standHeight + (crouchHeight − standHeight)
755
791
  9. `x y z vx vy vz` are clamped to [−1e9, 1e9] and rounded to float32, `duck` is rounded to float32, and a −0 among them is written as 0. The next sub-step starts from these values.
756
792
 
757
793
  The procedures, each reading and writing the sub-step's state:
758
- - **`duck()`**: `rate = h / duckTime`. When `duckDown`: `next = min(1, duck + rate)`; when not `onGround`, `z = z + (hull(duck) − hull(next))` (in the air the hull shrinks from below, its top staying put, as Source's in-air duck lifts the origin: a crouch-jump clears higher ledges); then `duck = next` and `ducking = true`. Otherwise, when `duck > 0`: `next = max(0, duck − rate)`; `zn = z` when `onGround`, else `z − (hull(next) − hull(duck))` (in the air it grows back downward); if `fits(x, y, zn, hull(next))`, then `duck = next`, `z = zn`, and `ducking = false` when `duck = 0`; if it does not fit, `duck`, `z` and `ducking` stay as they are. Otherwise (duck not held and `duck = 0`) nothing changes. `ducking` is therefore cleared only on the sub-step that brings `duck` down to 0, never set or cleared by anything but `duck()`: a body read with `ducking` set and `duck` 0 keeps crouch speed until a duck is pressed and released.
794
+ - **`duck()`**: `rate = h / duckTime`. When `duckDown`, not `onGround` and `airDuck` is 0: `duck`, `z` and `ducking` stay as they are (a duck held from the ground stays, none starts or deepens, and no hold lifts the feet). Otherwise when `duckDown`: `next = min(1, duck + rate)`; when not `onGround`, `z = z + (hull(duck) − hull(next))` (in the air the hull shrinks from below, its top staying put, as Source's in-air duck lifts the origin: a crouch-jump clears higher ledges); then `duck = next` and `ducking = true`. Otherwise, when `duck > 0`: `next = max(0, duck − rate)`; `zn = z` when `onGround`, else `z − (hull(next) − hull(duck))` (in the air it grows back downward); if `fits(x, y, zn, hull(next))`, then `duck = next`, `z = zn`, and `ducking = false` when `duck = 0`; if it does not fit, `duck`, `z` and `ducking` stay as they are. Otherwise (duck not held and `duck = 0`) nothing changes. `ducking` is therefore cleared only on the sub-step that brings `duck` down to 0, never set or cleared by anything but `duck()`: a body read with `ducking` set and `duck` 0 keeps crouch speed until a duck is pressed and released.
759
795
  - **`support(ramp, px, py)`**, the ramp top's highest z under the footprint: with `m0`, `m1`, `u` = `minX`, `maxX`, `px` for `axis` 0 and `minY`, `maxY`, `py` for `axis` 1: `span = m1 − m0`; `slope = (hi − lo) / span`; `u' = u + r` when `slope > 0`, `u − r` when `slope < 0`, else `u`; result `lo + slope · clamp(u' − m0, 0, span)`.
760
796
  - **`overlaps(px, py, solid)`**: `px + r > minX`, `px − r < maxX`, `py + r > minY` and `py − r < maxY`, all strict.
761
797
  - **`fits(px, py, pz, height)`**: false when any solid `overlaps(px, py, solid)` with `pz + height > minZ` and `pz < top − 0.0001`, where `top` is a box's `maxZ` or a ramp's `support(ramp, px, py)`; otherwise true.
762
798
  - **`traceDown(px, py, pz, down, up)`**: over the solids that `overlaps(px, py, solid)` with `top ≤ pz + up` and `top ≥ pz − down`, where `top` is a box's `maxZ` or a ramp's `support(ramp, px, py)`: `g`, the largest `top` of a box or of a ramp with `1 / sqrt(1 + slope · slope) ≥ standable` (`slope` as in `support`), and `steep`, the largest `top` of any other ramp (one too steep to stand on). The result is `g`; none when there is no `g`, or when there is a `steep` and `steep > g` (a hull traced down stops on the steep ramp first, and there is no ground under it).
763
- - **`checkGround()`**: when `vz > 0.1`, `onGround = false`. Otherwise, with `g = traceDown(x, y, z, groundTrace, groundSnap)`: when there is one, `z = g` and `onGround = true`; when there is none, `onGround = false`.
764
- - *Starting inside.* A hull that starts a move more than `skin` inside a solid (the box or ramp grown by it holds its point that deep from every face) was put there by something other than its own moves: a bump, a teleport, a module's write. The move's own tests never see such a solid (every face's entry is behind the point), so `slab` and `wedge` report it first: a hit at `t = 0` whose normal is the solid's least deep face, and whose `depth` is how deep, and `slideMove` pushes the hull out through that face before it moves. The face is never the solid's bottom: a body pushed sideways into a wall that stands on a floor, deeper than its own height, would be pushed down through the floor. Nor is it a face that points up (a box's top, a ramp's top or cap) that another solid covers. `covered(q, p, self)`, for the point `p` and the point pushed out through the face, `q = (px + nx · depth, py + ny · depth, pz + nz · depth)` with the face's unit normal `n` and its depth, is true when some solid but `self` has `q` `inside` it and not `p` (a solid the hull is already in is no lid on the way out: it pushes the hull out in turn). A body pushed sideways into a wall whose top meets a ceiling or a roof, deeper than its own height, would otherwise be pushed up onto the wall's top, inside the ceiling, and from there (the ceiling's bottom never being the way out) out over it. `inside(px, py, pz, solid, height)` is true when the point is more than `skin` inside every face of the solid grown by the hull: for a box, `px − (minX − r)`, `(maxX + r) − px`, `py − (minY − r)`, `(maxY + r) − py`, `pz − (minZ − height)` and `maxZ − pz` are all greater than `skin`; for a ramp, each of `wedge`'s seven planes' depth `−(nx · px + ny · py + nz · pz − e) · inv` is. `covered` tests every solid of the world but `self`, every box and every ramp, never only a broad phase's candidates, so a broad phase gives the same answer as none. A hull's own moves keep it `skin` clear of every face they meet, so a body that nothing else moves meets this rule only after starting a move at least `skin` behind a face (which the touching rule leaves unentered). A hit on the way in has `depth` 0.
799
+ - **`unit`**: `radius / 16` (`radius / 50` in `vehicle`, each preset's default radius) when that is under 0.5, a game in other units, so these few distances and speeds are the same in its units; otherwise 1, a room in the defaults' units with its own hull, where the step does not depend on the hull.
800
+ - **`checkGround()`**: when `vz > 0.1 · unit`, `onGround = false`. Otherwise, with `g = traceDown(x, y, z, groundTrace, groundSnap)`: when there is one, `z = g` and `onGround = true`; when there is none, `onGround = false`.
801
+ - *Starting inside.* A hull that starts a move more than `skin` inside a solid (the box or ramp grown by it holds its point that deep from every face) was put there by something other than its own moves: a bump, a teleport, a module's write. The move's own tests never see such a solid (every face's entry is behind the point), so `slab` and `wedge` report it first: a hit at `t = 0` whose normal is the solid's least deep face, and whose `depth` is how deep, and `slideMove` pushes the hull out through that face before it moves. The face is never the solid's bottom: a body pushed sideways into a wall that stands on a floor, deeper than its own height, would be pushed down through the floor. Nor is it a face that points up (a box's top, a ramp's top or cap) that another solid covers. `covered(q, p, self)`, for the point `p` and the point pushed out through the face, `q = (px + nx · depth, py + ny · depth, pz + nz · depth)` with the face's unit normal `n` and its depth, is true when some solid but `self` has `q` `inside` it and not `p` (a solid the hull is already in is no lid on the way out: it pushes the hull out in turn). A body pushed sideways into a wall whose top meets a ceiling or a roof, deeper than its own height, would otherwise be pushed up onto the wall's top, inside the ceiling, and from there (the ceiling's bottom never being the way out) out over it. `inside(px, py, pz, solid, height)` is true when the point is more than `skin` inside every face of the solid grown by the hull: for a box, `px − (minX − r)`, `(maxX + r) − px`, `py − (minY − r)`, `(maxY + r) − py`, `pz − (minZ − height)` and `maxZ − pz` are all greater than `skin`; for a ramp, each of `wedge`'s seven planes' depth `−(nx · px + ny · py + nz · pz − e) · inv` is. `covered` asks about every solid of the world but `self`, every box and every ramp. Only a solid whose box grown by the hull (`r` on x and y, `height` under it) holds `q` can answer yes, so a port may bound the question by a tree of those boxes: a boolean of a set, the same with every witness kept. A hull's own moves keep it `skin` clear of every face they meet, so a body that nothing else moves meets this rule only after starting a move at least `skin` behind a face (which the touching rule leaves unentered). A hit on the way in has `depth` 0.
765
802
  - **`slab(px, py, pz, dx, dy, dz, box, height)`**, a point against the box grown by the hull, `[minX − r, maxX + r] × [minY − r, maxY + r] × [minZ − height, maxZ]`. First *Starting inside*: when `px − lo`, `hi − px`, `py − lo`, `hi − py`, `pz − lo` and `hi − pz` (each axis's grown `lo` and `hi`) are all greater than `skin`, the test reports a hit at `t = 0` and stops. Its face is the least of five, in this order, a later one only when strictly smaller: `px − lo` with normal (−1, 0, 0), `hi − px` with (1, 0, 0), `py − lo` with (0, −1, 0), `hi − py` with (0, 1, 0), and `hi − pz` with (0, 0, 1) (never `pz − lo`, the bottom), the top only when it is not covered (`covered` of the point pushed out through it, `(px + 0 · hz, py + 0 · hz, pz + 1 · hz)` with `hz = hi − pz`, which a port tests only when `hz` would win); `depth` is that least value. Otherwise: `tMin = 0`, `tMax = 1`, no axis. For x, then y, then z, with that axis's `p`, `d` and grown `lo`, `hi`:
766
803
  - when `|d| < 0.000000001` (the move is parallel to this axis's faces): unless `p > lo` and `p < hi`, the test reports no hit and stops; otherwise go to the next axis.
767
804
  - `t1 = (lo − p) / d`, `t2 = (hi − p) / d`, `sign = −1`; when `t1 > t2`, swap them and `sign = 1`.
@@ -784,14 +821,22 @@ The procedures, each reading and writing the sub-step's state:
784
821
  6. `into = vx · nx + vy · ny + vz · nz`; when `into < 0`: `vx = vx + nx · (−into)`, `vy = vy + ny · (−into)`, `vz = vz + nz · (−into)`.
785
822
  7. when there is a previous normal `q`: the crease `c = (qy · nz − qz · ny, qz · nx − qx · nz, qx · ny − qy · nx)`; `L = cx · cx + cy · cy + cz · cz`; when `L > 0.000001`: `inv = 1 / sqrt(L)`, `c = (cx · inv, cy · inv, cz · inv)`, `along = vx · cx + vy · cy + vz · cz`, `v = (cx · along, cy · along, cz · along)`.
786
823
  8. the previous normal is now `n`; stop when `vx · vx + vy · vy + vz · vz < 0.00000001`.
787
- - **`groundMove()`**: save `(x0, y0, z0)` and `v0`. `slideMove(h)`; save its position `(x1, y1, z1)` and velocity `v1`, and `flat = ax · ax + ay · ay` with `ax = x1 − x0`, `ay = y1 − y0`. Restore `(x0, y0, z0)` and `v0`. `lift = stepHeight + skin`; `(t, ·) = sweep(x0, y0, z0, 0, 0, lift)`; `zl = z0 + lift · t`. When `t > 0` and `fits(x0, y0, zl, H)`: `z = zl`; `slideMove(h)`; `g = traceDown(x, y, z, stepHeight · 2, 0.001)`, and `z = g` when there is one; `stepped = bx · bx + by · by` with `bx = x − x0`, `by = y − y0`; when there is a `g` and `stepped > flat + 0.001`: `vz = 0`, and the stepped move stands. In every other case the position and velocity are `(x1, y1, z1)` and `v1`. (A ramp too steep to stand on under the stepped hull leaves no `g`, so it is a wall here, never a step.)
824
+ - **`groundMove()`**: save `(x0, y0, z0)` and `v0`. `slideMove(h)`; save its position `(x1, y1, z1)` and velocity `v1`, and `flat = ax · ax + ay · ay` with `ax = x1 − x0`, `ay = y1 − y0`. Restore `(x0, y0, z0)` and `v0`. `lift = stepHeight + skin`; `(t, ·) = sweep(x0, y0, z0, 0, 0, lift)`; `zl = z0 + lift · t`. When `t > 0` and `fits(x0, y0, zl, H)`: `z = zl`; `slideMove(h)`; `g = traceDown(x, y, z, stepHeight · 2, 0.001)`, and `z = g` when there is one; `stepped = bx · bx + by · by` with `bx = x − x0`, `by = y − y0`; when there is a `g` and `stepped > flat + gain`: `vz = 0`, and the stepped move stands, with `gain = 0.001 · unit · unit` (*`unit`* above). In every other case the position and velocity are `(x1, y1, z1)` and `v1`. (A ramp too steep to stand on under the stepped hull leaves no `g`, so it is a wall here, never a step.)
788
825
  - **`stayOnGround()`**, Source's `StayOnGround`: `g = traceDown(x, y, z, stepHeight, groundSnap)`; when there is one: `z = g` and `vz = 0`. A body running down a slope or a stair keeps its feet on it instead of hopping off each sub-step's drop.
789
- - **`friction()`**: `speed = sqrt(vx · vx + vy · vy)`. When `speed < 0.1`: `vx = 0`, `vy = 0`. Otherwise `control = speed < stopSpeed ? stopSpeed : speed`; `drop = control · friction · h`; `keep = max(0, speed − drop) / speed`; `vx = vx · keep`, `vy = vy · keep`.
826
+ - **`friction()`**: `speed = sqrt(vx · vx + vy · vy)`. When `speed < 0.1 · unit`: `vx = 0`, `vy = 0`. Otherwise `control = speed < stopSpeed ? stopSpeed : speed`; `drop = control · friction · h`; `keep = max(0, speed − drop) / speed`; `vx = vx · keep`, `vy = vy · keep`.
790
827
  - **`accelerate()`**: `add = wishSpeed − (vx · dirX + vy · dirY)`; nothing when `add ≤ 0`. `a = accelerate · h · max(wishSpeed, maxSpeed)` (`maxSpeed`, not `base`: `cap` never weakens the push), then `a = add` when `a > add`; `vx = vx + a · dirX`, `vy = vy + a · dirY`.
791
828
  - **`airAccelerate()`**: `add = min(wishSpeed, airMaxWishSpeed) − (vx · dirX + vy · dirY)`; nothing when `add ≤ 0`. `a = airAccelerate · wishSpeed · h`, then `a = add` when `a > add`; `vx = vx + a · dirX`, `vy = vy + a · dirY`.
792
829
 
830
+ *World cost.* What a world costs a room is what is near a hull, not its size, so `authority validate` warns a creator by a count (TypeScript `worldCost`, `WorldCost.MAX_OPS_PER_STEP`), never by a clock or a machine: 16 bodies dropped on seeded solids walk 120 ticks through the reference step, and every tree entry a query walks is one operation, every exact test of a solid (each candidate, and each solid *covered* tests) seven, and every sweep test of a wedge 78, the ratios of what they cost a c2-standard-4. A world averaging more than 5,000 a step is refused (`world_cost`): no world under that has ended a room of ten seats there, and worlds a little over have, in one run of two. It is a warning, not protection: it reads a world by where its seeded bodies walk, so a world dense only where its module spawns seats reads low, and one built to dodge the walk reads nothing. A server enforces nothing here: its CPU limits and mean budget, which end such a room, are the host's protection.
831
+
793
832
  *Posed solids.* A step may be handed a tick's *posed solids* beside the world (TypeScript `posedWorld`; §17.11 *Lifts* poses them): each an axis-aligned box `[min, max]`, a box of half extents `(hx, hy, hz)` about centre `c` turned about z by an angle given as its sine and cosine (`obox`; the pair is the yaw's `psincos`), or an upright disc of `radius` about `c` with half height `hz`. They are indexed after the world's solids, in the order given, and every procedure above treats them as solids: `overlaps` (a box's footprint test; an obox's strictly on its four axes, `|c·dx + s·dy| < hx + r(|c| + |s|)` and `|−s·dx + c·dy| < hy + r(|c| + |s|)`, after its bounding box's; a disc's when the hull square's nearest point is strictly inside the circle, `ax² + ay² < R²` with `ax = max(|dx| − r, 0)`), a flat top (`maxZ = cz + hz`, standable as a box's), `fits`, `traceDown`, `inside` and `covered` (over every solid, posed ones included). In `sweep` a posed box is a box (`slab`); an obox is `wedge`'s plane method over ten planes in this order, each normal made unit as `wedge` makes it: its four faces `±u`, `±v` (`u = (c, s, 0)`, `v = (−s, c, 0)`) at `±(u·C) + hx + r(|c| + |s|)` and `±(v·C) + hy + r(|c| + |s|)` (u+, u−, v+, v−), the hull's four `±x`, `±y` at `±cx + r + hx|c| + hy|s|` and `±cy + r + hx|s| + hy|c|` (x+, x−, y+, y−), the bottom `(0, 0, −1)` at `−minZ + H`, the top `(0, 0, 1)` at `maxZ` (a lid: *Starting inside* takes it only when uncovered); a disc is first *Starting inside* (more than skin inside its z span and its grown footprint: depth `R + r − max(|dx|, |dy|)` along the axis with the larger, x on a tie, when both `|dx|, |dy| ≤ r`, else `R − |q|` along `q/|q|`, `q` the offset beyond the hull square; out through that side, or its top when shallower and uncovered, never its bottom), then the earliest hit of its pieces, the x box `[cx − R, cx + R] × [cy, cy]`, the y box `[cx, cx] × [cy − R, cy + R]` (each by `slab`, z `[minZ, maxZ]`) and four upright cylinders of radius `R` about `(cx ∓ r, cy ∓ r)` in the order (−,−), (+,−), (−,+), (+,+), z `[minZ − H, maxZ]` (the circle's entry `t = (−b − √(b² − a·c))/a`, `a = dx² + dy²`, `b = ex·dx + ey·dy`, `c = ex² + ey² − R²`, none when the discriminant is negative, inside the circle all along when `a < 1e-18` and `c < 0`; then z as `slab`'s axis; a side hit's normal radial at the hit point), a later piece only when strictly earlier. A query visits a posed solid when its bounding box, grown by `r` on x and y and the query's margin, meets the query's box (the broadphase's own test; the result never depends on it). Without posed solids every result is as above, bit for bit. Held to `lifts.json`'s `presetOnLift`.
794
833
 
834
+ *Rays.* `ctx.world.ray(origin, dir, { range })`, in `init` and every hook of any room, answers the module's own questions of the static world: the first solid along the ray, or `null`. A physics room builds the sandbox's tree of its world at its first ray, inside that call. It is the sandbox's alone: no client computes it, nothing of it crosses the wire and no conformance file holds it, so it is an append-only addition under §17.1 (no protocol bump). It reads no state and changes none, the step's included, and it sees the world only: not bodies, lifts or posed solids (`ctx.rewind.hitscan` judges entities, §17.5; a room that has both asks both). The world is the module's `world` (or its `worlds` entry, §17.2) as validated above, in any room: a preset's, a physics room's (§17.10), or one with neither, where a world the rules refuse makes the call throw a `TypeError` naming the first bad value, and never fails the load, because such a module may mean something else by the key. None exported is the empty world. In float64: `origin` and `dir` are objects whose `x`, `y` and `z` are all finite numbers, and anything else answers `null`, as does a `dir` of length 0. `range` is `options.range` when a number ≥ 0, else no limit, as in `hitscan`. `d` is `dir` divided by its largest magnitude, then by `sqrt(d · d)`; a component of magnitude below 1e-150 then reads 0. A solid is its open interior: a box's six slabs, a ramp's those and the half-space under its top (`z < lo + s · (c − m)`, with `c` its axis's coordinate, `m` that axis's min and `s = (hi − lo) / (max − m)`). For an axis with `d = 0` the ray must lie strictly between the slab's bounds; otherwise the slab's bounds cross at `(bound − o) · (1 / d)`. For the top, `f = o.z − lo − s · (o.c − m)` and `g = d.z − s · d.c`: with `g = 0` the ray must have `f < 0`; otherwise it crosses at `−f / g`, entering for `g < 0` and leaving for `g > 0`. `near` is the greatest entry (`-Infinity` if none) and `far` the least exit; the ray enters the solid when `near < far` and `far > 0`, at `t = max(near, 0)`, so touching a face, an edge or a plane is not entering it, and neither is leaving from one. The answer is the least `t ≤ range`, a tie going to the lower index, boxes then ramps: `{ dist: t, point: o + d · t, normal, inside: near < 0, solid: index }`. `normal` is the unit normal of the face that gave `near` (slabs by axis order, then the top, each only when strictly greater; a top's is `(−s · k, 0, k)` along x, `(0, −s · k, k)` along y, `k = 1 / sqrt(1 + s²)`, with 0 for `−s · k` when `s` is 0), all zeros when `inside`. The sandbox walks the step's tree nearest child first, and the answer is the one every solid in index order gives (`authority-host/bench/world-ray.ts` `bruteRay`, held to it bit for bit in `test/world-ray.test.ts`).
835
+
836
+ *The shot world.* A module's `shotWorld` (or its `shotWorlds` entry for the room's match key, shaped as `worlds`, else `shotWorld`) is a second world of boxes and ramps under the same rules, with an optional `tolerance`, a finite number ≥ 0. Only `ctx.world`'s rays read it: `ctx.world.ray(origin, dir, { world: 'shot' })` asks it the way *Rays* says. Nothing else answers for it: the room's own world would answer a shot wrongly and nobody would know. So a room with no shot world never answers that key or `ctx.world.claim`: the call throws a `TypeError` naming `no_shot_world`, a load fault in `init`, a strike in any other hook. A load reads no module's text. `authority validate` does, so it fails `shot_world_missing` on a bundle that says either (`.world.claim(`, or `world` with the value `shot`) with none for its room, on code the fuzz never reaches; a key built at run time says nothing to read and is the call's to answer. A map is held in every room, as `no_shot_world` at load, whatever a client's key: `shotWorlds` needs a `shotWorld` for the rooms it does not name, and a module with any shot world and a `worlds` map needs a `shotWorlds` entry for each of its names. It has its own tree and no hash; no client knows it, nothing of it crosses the wire, no step reads it, and a world's hash, tree and step are the same with one or none. So it too is append-only under §17.1. It is validated in the load phase, under the bundle's limit, and a refused one fails the load, naming the value: `shotWorld: boxes[3][5] ...`. A room's world and shot world together hold at most `MAX_ROOM_SOLIDS` (49,152) solids, by the same refusal. It counts against the module: it is in the bundle (the module limit), its solids are the sandbox's heap, and each load counts what it costs. Seeded rays (`ShotWorldCost`, `shot-world.ts`) from over its solids walk the tree, and `InitOut.shotWorld` carries `{ solids, opsPerRay }`, tree entries walked and solids tested, the same on every machine; `authority validate` fails `shot_world_cost` past `MAX_OPS_PER_RAY`. `genex world shots` fits one from a scene (`WORLD-SCENE.md`).
837
+
838
+ *A claimed stop.* `ctx.world.claim(origin, dir, claimed, { range, tolerance, pierce, rule })` holds a shooter's stop, traced on its own render mesh, to the shot world. `claimed` is a number, and anything else says the shot flew on; below 0 reads as 0. A client sends it on `moveZ`, which neither movement preset reads and every transport carries: `claimToAxis` and `axisToClaim`, [0, range] across the axis and 1 for flying on. `server` is `ray`'s `dist`, or `range` when nothing stops the ray (`Infinity` without one). `tolerance` is the option when a finite number ≥ 0, else the shot world's, which defaults to 0. It is a distance off the surface: with `f` the absolute dot product of the ray's unit direction and the stop's normal (1 when the ray starts inside, or nothing stops it), `slack = tolerance / max(f, 1/4)`. With `stop = min(claimed, range)`: `stop > server + slack` is `refused`, `dist` is `server`, unless `pierce` is a number above 0 and the wall stands in `pierce`: from `server` toward `stop - slack` the ray meets one wall, at most `pierce` long, where a wall's surfaces lie within `pierce` of each other, a second surface past that is a second wall, and at most `MAX_CLAIM_WALKS` surfaces are walked, past which it is refused; then it is `pierced` with `dist` = `stop`. `stop < server - slack` is `capped`, `dist` = `server - slack`. Otherwise `accepted`, `dist` = `stop`. A `rule` function has the last word, called with the answer as it stands: a number ≥ 0 replaces `dist` and the verdict is `ruled`, and anything else changes nothing. The answer is `{ dist, verdict, claimed, server, tolerance, slack, through, solid, normal, point }`, `server` null when nothing stops the ray, `point` null when `dist` is `Infinity`. A ray that is none answers null. So a hostile client's whole margin is `slack` past a surface: a body within it behind a wall can be hit, a body further cannot, however thin the wall.
839
+
795
840
  **Portable sine and cosine.** `psin(a)` and `pcos(a)` are the sine and cosine of a float64 `a` that every port computes to the same bits. A preset that needs an angle's sine or cosine calls these and never the platform's `sin` and `cos`, which differ in the last bit between C libraries, .NET, V8 and GDScript. They are Cody–Waite reduction by π/2 with fdlibm's three-part split of π/2 (`e_rem_pio2.c`), followed by fdlibm's sine and cosine polynomials (`k_sin.c`, and `k_cos.c` in FreeBSD msun's form). The polynomials are in Horner form, and the kernels' tail argument is dropped. The arithmetic is `firstperson`'s (*Arithmetic*: float64, the written order, no fused multiply-add) using `+ − ·`, `floor`, and one division by 4 (exact here).
796
841
 
797
842
  The constants are float64 values with the bits shown, each equal to the exact value beside it. A port writes each constant in one of three ways: by its bits; as `M / 2^k` evaluated in float64 (`M` is an integer below 2^53 and `2^k` a power of two, both exact, so the one division is exact); or as a decimal literal, but only in a language that parses decimals correctly rounded (a port that cannot rely on that, GDScript among them, uses the `M / 2^k` form).
@@ -872,6 +917,7 @@ A port may compute steps 2–5 once for both functions, since their results are
872
917
  | `skin` | 0.03 | [0, 100] | Clearance kept from a surface |
873
918
  | `jumpBit`, `boostBit`, `driftBit` | 1, 2, 4 | integer, [0, 2^24) | Button masks: any shared bit counts as held; 0 disables |
874
919
  | `substeps` | 2 | integer, [1, 8] | Sub-steps per step |
920
+ | `capZeroJump` | 1 | integer, [0, 1] | 1: a jump works at `cap` 0. 0: a press at `cap` 0 is no jump (a freeze time) |
875
921
  | `world` | 2615243109 | integer, [0, 2^32 − 1] | The collision world's hash, as `firstperson`'s |
876
922
 
877
923
  *Body.* The step reads and writes `x y z vx vy vz yaw spin fuel mstate`, and reads `cap`. It never writes `pitch` or `duck`, which stay as they are. Each field is read sanitized:
@@ -894,7 +940,7 @@ One step with input `(moveX, moveY, buttons)` and length `dt`:
894
940
  2. `buttons`: an integer in [0, 2^24), else 0. `jump = (buttons AND jumpBit) ≠ 0`; `boost` and `drift` likewise, with `boostBit` and `driftBit`.
895
941
  3. `dt` as in topdown's step 2; `h = dt / substeps`.
896
942
  4. `yaw = wrap(yaw)` on the yaw as read when its magnitude is at most 1e9; otherwise 0.
897
- 5. When `dt > 0`: `pressed = jump and not jumpHeld`, then `jumpHeld = jump`, then run `substeps` sub-steps in turn, each of length `h`. When `dt = 0`: no sub-steps run, and `jumpHeld` stays as read.
943
+ 5. When `dt > 0`: `pressed = jump and not jumpHeld and (capZeroJump is 1 or cap ≠ 0)`, then `jumpHeld = jump`, then run `substeps` sub-steps in turn, each of length `h`. When `dt = 0`: no sub-steps run, and `jumpHeld` stays as read.
898
944
  6. Write `x y z vx vy vz yaw spin fuel` as the last sub-step left them (with no sub-step: as read, with step 4's `yaw`, and −0 as 0), and `mstate = onGround + 4 · jumpHeld`.
899
945
 
900
946
  One sub-step:
@@ -934,6 +980,8 @@ The procedures, each reading and writing the sub-step's state. `c` and `s` are s
934
980
 
935
981
  ### 17.8 Conformance
936
982
 
983
+ `conformance/authority/room-key.json` (§17.2 *Choosing a room*, §17.7 *World*) holds the match key and room code tables, what each way into a room sends, the walk of a join by code and the world a key picks. A client names no room with a key or code the file refuses.
984
+
937
985
  `packages/netcode-bench/conformance/authority/presets/<name>.json` holds each preset's vectors: parameters, a starting body, an input log with optional per-step `dt`, and the expected body after every step. Non-finite inputs are spelled `"NaN"`, `"Infinity"` and `"-Infinity"`. `firstperson.json`'s and `vehicle.json`'s cases each also carry the world they step in and its hash. The file holds only parameters and worlds a room accepts: refusing a config (a parameter out of range or not in the table, a refused world) is the server's (§17.4), which `test/authority-firstperson.test.ts` and `test/authority-vehicle.test.ts` hold to §17.7's rules, and is not something an engine's step does. An engine's port of a preset conforms when every field of every step lands within the file's `tolerance` (1e-4), or within a case's own `tolerance` where the case states one: every `discriminate-` case of `vehicle.json` states 0 and is matched bit for bit, because the mistakes it was chosen for (a fused multiply-add, the platform's sine) move a field by less than 1e-4, and only an exact match catches them. The TypeScript reference is held to the same file in `test/authority-presets.test.ts` (`firstperson.json` and `vehicle.json`, bit for bit and with and without the broadphase, in `test/authority-firstperson.test.ts` and `test/authority-vehicle.test.ts`). The expected values are not that reference's output: `<name>.reference.mjs` beside each file is a second implementation written from this section's text, imports nothing, and prints the file byte for byte (the same test regenerates and compares it). Until an engine's port passes, that engine renders its own entity from the server's state without prediction.
938
986
 
939
987
  `projectile.json` beside them holds §17.9's flight vectors the same way (`projectile.reference.mjs` prints it): each case is a tick rate, a launch (`x y z vx vy vz g t0`) and samples `{ T, expect: { x, y, z, vx, vy, vz } }` at whole and fractional ticks, including ticks before the launch and hostile values. The `order-*` cases sample ticks where a reordered, float32-only or fused (multiply-add) evaluation lands on another float32, so such a port fails bit for bit. An engine's flight function conforms on the same terms as a preset's step.
@@ -1016,7 +1064,7 @@ Any other key, or a value outside its range, is a bad config (§17.4). The world
1016
1064
 
1017
1065
  **Joining.** Authority join options gain `caps`, an array of at most 16 strings of at most 32 characters each; absent is `[]`. Every authority room refuses any other `caps` value with 400. A client that implements this section sends `caps: ['physics@1']`. A physics room refuses a join whose `caps` lacks `physics@1` with **426** (`physics@1 required`), so a client whose decoder predates the body fields is turned away instead of mis-decoding them; a room without physics ignores a well-formed `caps`. An SDK sends `physics@1` only once its drawing passes `physics-draw.json` and, for an engine SDK, its own headless probe on the pinned core (*Conformance*); the bench's feel room adds its bar for an engine once that engine's driver advertises `physics` (§17.8).
1018
1066
 
1019
- **The static world.** In a physics room the module's `world` export (§17.7's *World*, validated by its rules whatever the preset; none is the empty world) is the room's static geometry, imported into the core once with `addStaticWorld` (C§6.5). It never travels in `InitOut`, which a full world would overflow. After `__genex_init` succeeds, the host calls a third guest entry, `__genex_world()`, once, as a call of its own held to `init`'s CPU and memory limits; it returns the validated world as base64 (RFC 4648, standard alphabet, padded) of exactly the bytes §17.7's hash reads: the box and ramp counts, then every value as a little-endian binary32, −0 written as +0. The empty world (no export, or no boxes and no ramps) returns exactly `''` and stands for §17.7's empty world (hash 2615243109); the 8-byte text of two zero counts is refused. The text is at most 155,660 characters (`MAX_WORLD_TEXT_CHARS`: 4 · ⌈(8 + 24 · 4096 + 36 · 512) / 3⌉). Canonical base64 is text that decoding and encoding again gives back unchanged (no whitespace, the padding present, the unused bits of the last character zero). The host refuses it as a bad config, and the room does not open, when the call throws, runs past its limits or returns anything but a string, or the text is longer, not canonical base64, of a length that disagrees with its counts (8 + 24 · boxes + 36 · ramps bytes), a world §17.7 refuses, or, with `firstperson` or `vehicle`, a world whose hash (recomputed by the host from the bytes) is not the resolved `params.world`. The empty world is not imported. A core failure while the host builds the world (`World.create` or `addStaticWorld` refused, for example `limit-static` under a lowered `staticPieces`) is a bad config too; `internal` there refuses the room with 503 instead.
1067
+ **The static world.** In a physics room the module's `world` export (§17.7's *World*, validated by its rules whatever the preset, so at §17.7's caps of 32,768 boxes and 4,096 ramps; none is the empty world) is the room's static geometry, imported into the core once with `addStaticWorld` (C§6.5). It never travels in `InitOut`, which a full world would overflow. After `__genex_init` succeeds, the host calls a third guest entry, `__genex_world()`, once, as a call of its own held to `init`'s CPU and memory limits; it returns the validated world as base64 (RFC 4648, standard alphabet, padded) of exactly the bytes §17.7's hash reads: the box and ramp counts, then every value as a little-endian binary32, −0 written as +0. The empty world (no export, or no boxes and no ramps) returns exactly `''` and stands for §17.7's empty world (hash 2615243109); the 8-byte text of two zero counts is refused. The text is at most 1,245,196 characters (`MAX_WORLD_TEXT_CHARS`: 4 · ⌈(8 + 24 · 32768 + 36 · 4096) / 3⌉). A world past the room's `limits.staticPieces` (default 4,608) raises them for that room alone: `World.create` gets `staticPieces` equal to the world's solids and `memoryBytes` raised by 2,048 for each (`physicsLimitsForWorld`, up to the core's ceiling), so the world leaves the room what the default gives it. A world the limits hold changes nothing in the description, so `physicsHash` stays as it was. A module that lowered `staticPieces` keeps its limit, and its world is refused. A room whose world holds more than 4,096 boxes or 512 ramps (`physicsWorldPredicts`) does not predict (§17.11), whatever `predict` says: a client's core refuses such a world in the C ABI every engine ships, and a keyframe's rebuild (C§20.1) takes time and memory in proportion to the world. Its bodies are drawn as in a room that does not ask to, on every seat alike, and `genex-authority-dev validate` warns (`physics_predict_world`). Canonical base64 is text that decoding and encoding again gives back unchanged (no whitespace, the padding present, the unused bits of the last character zero). The host refuses it as a bad config, and the room does not open, when the call throws, runs past its limits or returns anything but a string, or the text is longer, not canonical base64, of a length that disagrees with its counts (8 + 24 · boxes + 36 · ramps bytes), a world §17.7 refuses, or, with `firstperson` or `vehicle`, a world whose hash (recomputed by the host from the bytes) is not the resolved `params.world`. The empty world is not imported. A core failure while the host builds the world (`World.create` or `addStaticWorld` refused, for example `limit-static` under a lowered `staticPieces`) is a bad config too; `internal` there refuses the room with 503 instead.
1020
1068
 
1021
1069
  **Keys and kinds.** A body's key is its entity's id, and a joint's key is the module's. Entity ids are wider than contract keys (C§3), so an entity with a body must have an id that is a contract key: 1 to 64 bytes of `[A-Za-z0-9_.:\-]`, not `__proto__`, `constructor` or `prototype`, and not starting with `static:`. Host ids (`e<n>`) fit; ids a module picks may not. A physics kind (a `BodyDesc`'s `kind`, which is the body's own and independent of its entity's `kind` field) is 1 to 32 bytes of the same charset; `''` (or absent) means none, and the host passes it to the core absent. The prelude and then the host refuse any other body key or kind with `invalid-key` before the command reaches the core; every other key a command carries (colliders, joints, filters) is the core's to judge (C§3).
1022
1070
 
@@ -1088,7 +1136,7 @@ Every authority worker sets Colyseus's `Encoder.BUFFER_SIZE` to 1 MiB (`AUTHORIT
1088
1136
  - `physics-draw.json`, each engine SDK's own probe and, once its driver advertises `physics`, the bench's feel room hold each engine to these rules (*Conformance*).
1089
1137
 
1090
1138
  **Limits and failures.**
1091
- - **The physics meter.** The host measures its own thread CPU across steps 3 to 11 of each tick, plus, on a native core, the CPU the room's process spent on that tick (which the process measures itself, per round trip). A breach is a tick over 8 ms per 1/60 s of simulated time (8, 16 and 24 ms at 60, 30 and 20 Hz), or a mean over the last 300 ticks above 1 ms per 1/60 s (1, 2 and 3 ms a tick). A 120 Hz tick gets a 60 Hz tick's bounds, 8 ms and a 1 ms mean, not half of them: an owner-set limit (the owner's per-tick rule, 2026-11-03), so a 120 Hz room has twice the physics CPU per simulated second, as it weighs twice in placement (*Admission and placement*; `authorityPhysicsBudgetSixtieths` in `@genex-ai/multiplayer/protocol`: per simulated second, a room's budget is always its `AUTHORITY_PHYSICS_TICK_WEIGHT` times a 60 Hz room's). The guest's own CPU limits are per tick at every rate and never scale with `tickHz`. The mean slides: it is judged after every tick once 300 ticks have been measured since the room started or since the last breach, whichever is later, and a breach empties the window. A tick that breaches the per-tick bound is one breach, and the mean is not judged on it. Each breach is a strike, on the same count as the module's faults (§17.4). The third strike ends the room with reason `physics_budget` (4501). Physics CPU is not the guest's: it never counts toward the guest's budget, and it is reported apart in Studio's report line and the room's telemetry.
1139
+ - **The physics meter.** The host measures its own thread CPU across steps 3 to 11 of each tick, plus, on a native core, the CPU the room's process spent on that tick (which the process measures itself, per round trip). A breach is a tick over 8 ms per 1/60 s of simulated time (8, 16 and 24 ms at 60, 30 and 20 Hz), or a mean over the last 300 ticks above 1 ms per 1/60 s (1, 2 and 3 ms a tick). A 120 Hz tick gets a 60 Hz tick's bounds, 8 ms and a 1 ms mean, not half of them: an owner-set limit (the owner's per-tick rule, 2026-11-03), so a 120 Hz room has twice the physics CPU per simulated second, as it weighs twice in placement (*Admission and placement*; `authorityPhysicsBudgetSixtieths` in `@genex-ai/multiplayer/protocol`: per simulated second, a room's budget is always its `AUTHORITY_PHYSICS_TICK_WEIGHT` times a 60 Hz room's). The guest's own CPU limits are per tick at every rate and never scale with `tickHz`. The mean slides: it is judged after every tick once 300 ticks have been measured since the room started or since the last breach, whichever is later, and a breach empties the window. A tick that breaches the per-tick bound is one breach, and the mean is not judged on it. Each breach is a strike, on the same count as the module's faults (§17.4). The third strike inside 1200 ticks (`STRIKE_WINDOW_TICKS`: a steady mean breach makes three in 900 at any rate) ends the room with reason `physics_budget` (4501). Physics CPU is not the guest's: it never counts toward the guest's budget, and it is reported apart in Studio's report line and the room's telemetry.
1092
1140
  - **The host factor.** The meter's numbers are reference milliseconds: what the reference host (a DigitalOcean s5, Zen 5) measures. A room that passes the meter on one server passes it on every other, whatever its CPU. A worker measures itself once its core has loaded and before its first physics room. One reading is the parity scene (*The core*) on its own backend, a warm-up run and then three runs, their median physics CPU per tick over the reference host's. The host check takes up to five readings, 250 ms apart plus up to 250 ms at random (so workers booted together drift out of step), stopping once two are under the cap, and keeps the best (the least CPU per tick): contention from a busy sibling hyperthread or another process only ever adds CPU, so the least reading is the nearest to the host's own speed, the one the reference figure and the cap describe. That ratio, at least 1, is its host factor. Every bound above (per tick, mean) and a native round trip's deadline are multiplied by it, frozen when the room opens. A backend whose factor passes 3.5, or one of whose readings is not a positive number (the meter runs on the same clock), does not pass. Under `auto` a native core that does not pass gives way to the WebAssembly core it was checked against, measured the same way, and the native processes are stopped. A worker on which no backend passes runs no physics rooms: a physics admission there is refused 503, so the room is placed elsewhere, and the first physics room 60 s later measures again; a backend that passed is kept until the worker restarts. The decision is a typed status (`passed`, `fell_back`, `too_slow`, `unmeasured`) with every backend's readings, on the worker's load row (`physicsHostCheck`). The demo servers (car-ball, Netcode Park, the physics arena) run the same check through the same code. A physics room takes its admitted units times the host factor of the worker's capacity; it is billed its admitted units, the same on every host. The room's telemetry carries the factor (`physicsHostFactor`); a local tool (authority-dev) measures its own machine the same way.
1093
1141
  - **Memory.** Commands that would pass `memoryBytes` are refused by the core (`limit-memory`). A step that ends over it (`memoryExceeded`, C§11.1) ends the room at once with `module_memory` (4501).
1094
1142
  - **`internal`.** When a room's core answers `internal` (C§11.3: a WebAssembly trap; a native process that crashed, was killed, broke the frame protocol, or hung or ran away past its deadline, below), **that room alone** ends with reason `physics_internal`; no other room, and not the worker, notices. That is never a strike nor the module's fault, and it closes with **4500**, terminal, so no client rejoins into the same failure. A host error inside steps 3 to 11 (a throw out of the host's physics code, or out of a core call, which C§1.5 says never throws) ends that room the same way, with reason `physics_internal`.
@@ -1131,6 +1179,8 @@ The cushion a starve shows the buffer needs is built by the client, not owed by
1131
1179
 
1132
1180
  The ack stays exactly the real inputs consumed (absorbed ones included). `AuthorityPlayer.inRep` (number, written at every keyframe for a seat that sent `repeat@1`, absent otherwise) is the repeats it owes, so the state holds the seat's entity as stepped through input `ack + inRep`: the preset has taken `ack + inRep` steps, each repeat counted as the input it stood in for. A client that sent `repeat@1` compares its prediction with the state at input `ack + inRep` once it has sent that input; while `ack + inRep > sent` (a hitch drained the seat's buffer and the room repeated for inputs the client has not sent yet) no prediction of its own is at the state's input, so its reconcile waits at the input it compared at last, never past `sent` (the hitch clause of *The reconciler's ack* below; compared at `sent` instead, a held stick was pulled forward by the repeats and back by as many once the next inputs went out). A replay from the state replays the inputs after `min(ack + inRep, sent)` (`steppedThrough`; a value that is not a non-negative integer reads 0), and times input `seq` at tick `tick + (seq − (ack + inRep))` (seat rules, seat contacts, the client world); only a projectile's settling reads the true ack, since an input a repeat stood in for has not reached the module yet. Under a held input the prediction then matches the state exactly through every starve; an absorbed input that steered differently from its repeat is an ordinary correction. Held to `packages/netcode-bench/conformance/authority/input-repeat.json` (printed by `input-repeat.reference.mjs` beside it): its `rooms` cases are one seat's ticks (arrivals and the pacer's feed) and what the room hands the module, its `inRep`, `inStarve` and ack; its `clients` cases the stepped-through input and its `reconciles` cases the hitch clause alone (`reconcileThrough`: the input a reconcile compares at, from the one it compared at last, `held`), hostile values included; its `throttle` cases the throttle event by event (a keyframe's readings or a frame), its `seatClock` cases the clock other seats are drawn on, and its `tuning` the client constants, which every engine copy of the schema carries as generated constants (`gen-engine-schema.ts`), so a retune is a regeneration, never an edit of engine code.
1133
1181
 
1182
+ **A quiet seat.** The preset steps a body under inputs, so a seat that stops sending (a closed lid, a lost link, a client between scenes) holds where its last input left it, in the air too, for everyone else. `config.inputs.settleMs` (default absent; in [100, 60000]; no genre preset sets it) has a room with `firstperson` or `vehicle` step such a body once its seat has had no input for that long: under a stick at rest and no buttons, its aim kept, after any repeats, one step a tick, until it is on the ground and not moving. The module's `onInput` never hears these steps, the seat's ack does not move, and an input that arrives ends them at once: its client reconciles to the moved body as it would to any module write. A value under the longest hitch a game wants to sit through costs that hitch a correction.
1183
+
1134
1184
  **The reconciler's ack.** A client replays the inputs after the stepped-through ack from its input handle's replay ring, which Colyseus 0.18 sizes to (1000 ms + the patch interval) of inputs at the room's tick rate, times 1.5, at least 64 (TypeScript, C# and the native SDK alike: 180 at 120 Hz, 64 at 30 Hz), plus any copy the fast channel keeps (§17.12). A link that holds its downlink past that (TCP resending a lost segment twice on Wi-Fi) and then lets the backlog out over a few round trips hands the client states whose ack is still more than a ring behind its sends; reconciled at that ack, the replay skips the inputs gone from the ring, and an exact prediction is drawn back by their motion and then forward again (a held throttle in a 30 Hz vehicle room: four visible corrections, 423 units, after a 2.5 s hold). So the ack a client's reconciler is told is decided from the input the state holds its entity stepped through, `ack + inRep` (the plain ack when the setting is off) and never clamped to `sent`, by two clauses in order. The hitch's: past `sent` it is the ack the reconciler was told last, never past `sent` (no prediction of the client's is at the state's input yet; above). The ring's, on what the hitch's leaves (`stepped`): `stepped` unless it advanced while the oldest input after it (`stepped + 1`) is in neither the ring nor another copy the reconciler reads (TypeScript's replay falls back to the fast channel's copy; the C#, Unreal and Rust ports replay only from their shadow handle's ring): then it stays the ack it was last told, and the client keeps predicting on its own inputs until a state's ack is within the ring again, which reconciles every input. The hold lasts at most a ring's length of sends from the first held ack (`sent − heldFrom < ring`): a link whose acks stay that far behind is reconciled at them with what is left, and the hold is armed again only by an ack within the ring or a reset (an ack that moved back, such as a reconnect's). A hitch neither arms nor ends a hold: a backlog that lands caught up and stepped past `sent` is reconciled once that input is sent, the whole replay then within the ring. A port gives `input-repeat.json`'s `reconcilerAck` cases (both clauses, poll by poll, from `ack + inRep` as `stepped`) and its `reconciles` cases exactly (TypeScript `packages/multiplayer/src/authority/input-repeat.ts` `reconcilerAck`, from `stateThrough`); Godot's reconciler reads the room's own handle and is a labelled skip.
1135
1185
 
1136
1186
  **Keyframes.** A predicting room never patches on §17.6's timer: it patches once after every keyframe tick, so each patch's `tick` is a keyframe's, and its acknowledgement is exactly the inputs the room consumed through that tick. `AuthorityRoomState` appends `physDesc` string, `keyframe` number, `shapes` map<string, AuthorityShape> and `joints` map<string, AuthorityJoint>, none defaulted, written in predicting rooms only:
@@ -1179,7 +1229,7 @@ One pair `a`, `b` (`a` the lower id), in float64 in this order:
1179
1229
  7. The pass: every pair in id order; then further passes, up to `passes` in all, on the pairs whose contact (steps 1-3) is a side contact deeper than `slop` or a top contact at all (a stack a third seat moved back into is set again); after every pass that touched a pair, every seat pushed out of the world; a pass that touches none ends it. A push-out through a face no seat stands on (z under `supportCos`) is a *wall push*: what the contacts drove into a wall (the G6 judge reads it, TypeScript `ContactReport.walls`). A module may answer the first pass's pairs with its own rule (car-ball's release-1 sweep over its 60 Hz tick).
1180
1230
  8. The *ride*, in the seat-rule slot before the tick's preset steps, from every seat as it was before any moved: a seat whose base is inside a kinematic body's footprint and within `groundTrace` of its top is carried by that body's move to the next tick (`kinematicRule(t+1) − kinematicRule(t)`, x and y); else one standing on another seat (their top rule `stand` with `ride`, the lower's support standable, and the upper let down by `groundTrace` meeting it across the plane from above within `supportCos` of its support) by that seat's `vx vy` over the tick; a seat leaving upward (`vz > 0`) adds that velocity to its own.
1181
1231
 
1182
- **Seat rules.** A seat rule is what the module does to a seat's own entity at a tick it announced in advance (a kickoff that holds cars with `cap` 0 and sets `cap` 1 in its step of tick `go`): a function of the entity's fields and the tick that seat's input is applied on, changing the fields in place before that input's step. A client given it (TypeScript `connectAuthority({ seatRule })`, which may read the latest state, such as the announced `go`) runs it on your own entity before each predicted input `seq`, at tick `tick + (seq − ack)` of the state that acked through `ack` (*Input pacing*: one input a tick; `ack + inRep` for a client that sent `repeat@1`), handing it `seq` too (so a rule keyed to one of your inputs, such as the blast of a rocket you fired at input `s`, finds that input's tick as `tick − (seq − s)`), and, in an `inputDecay` room, on each other seat's body before each of its steps, at keyframe tick `+ j`. So a release is predicted, not learned a round trip late. Held to `seat-contacts.json` (your entity) and `physics-predict.json` (other seats).
1232
+ **Seat rules.** A seat rule is what the module does to a seat's own entity at a tick it announced in advance (a kickoff that holds cars with `cap` 0 and sets `cap` 1 in its step of tick `go`): a function of the entity's fields and the tick that seat's input is applied on, changing the fields in place before that input's step. A client given it (TypeScript `connectAuthority({ seatRule })`, which may read the latest state, such as the announced `go`) runs it on your own entity before each predicted input `seq`, at tick `tick + (seq − ack)` of the state that acked through `ack` (*Input pacing*: one input a tick; `ack + inRep` for a client that sent `repeat@1`), handing it `seq` too (so a rule keyed to one of your inputs, such as the blast of a rocket you fired at input `s`, finds that input's tick as `tick − (seq − s)`), and, in an `inputDecay` room, on each other seat's body before each of its steps, at keyframe tick `+ j`. So a release is predicted, not learned a round trip late. Held to `seat-contacts.json` (your entity) and `physics-predict.json` (other seats). A module that changes `cap` unannounced changes it for inputs on their way, which the client predicted at the old cap: a stick held through a lift from 0 lurches ahead by about speed × round trip when the client hears. Announced more than a round trip ahead and predicted by a seat rule, the same change takes no correction and every seat starts on one tick, at any ping.
1183
1233
 
1184
1234
  **Body rules.** A body rule is what the module does each tick to its dynamic bodies' velocities from the seats' entities (a scripted hit on a ball, a speed limit): a pure function of every seat's own entity at that tick (key order; position and velocity in room units, per second) and the bodies' states as the last step left them, returning the velocities to set; the module sends them as `setVelocity` commands from its `step` (after its other commands). A client given it (TypeScript `connectAuthority({ bodyRule })`) runs it in the client world before every predicted step, after the preset and kinematic poses: seats as *The client world* moves them (yours from the ring with its velocity, others with their keyframe velocity), each velocity it returns set on a simulated dynamic body it names, in its order. Rules that read anything else (events, timers, cooldowns) are not body rules: they stay corrections.
1185
1235
 
@@ -1220,7 +1270,7 @@ A port builds its world from `physDesc` and `staticWorld`, runs every event thro
1220
1270
 
1221
1271
  `packages/netcode-bench/conformance/authority/seat-contacts.json`, printed by `seat-contacts.reference.mjs` beside it, holds *Seat contacts*: each case is one client's `state` events (the ack, your entity, the other seats) and `send` events, with the case's step (a drift by velocity, float32) and rule (`bump`, spelt out in the file); a port must give your entity after every event exactly (tolerance 0). The cases: head-on, moving away, a replay after a swerve, two others either side of your id, a new state mid-pass, and a kickoff release (a case's `seatRule` `release` run on your entity before each input's step, at the state's `tick` plus `seq − ack`; the case's step moves a held entity by its velocity times its `cap`).
1222
1272
 
1223
- `packages/netcode-bench/conformance/authority/contact-policy.json`, printed by `contact-policy.reference.mjs` beside it (written from *Contact policy*, importing nothing), holds the contact policy over a test yard (`world`: a floor, four walls, a 22° and a 63° ramp, a goal pocket with a crossbar, a box canyon, a corridor and a ring of walls at head height) and named `policies` (a declaration and its movement preset's params each): `config` (declarations resolved, or the refused path), `classify` (a pair's `kind`, `n`, `depth`, `upperIsB`), `pairs` (one pair step per policy, each with the outcome's `j`, `way` and the way's unit `w`, what the module heard, and both seats after; the `stand: …` rows hold the stand test: a centred and an off-centre landing, the edge band resting, parting and walked away from, past the footprint, anywhere with `center` null, and a box's heading frame), `yield` (side shares against a wall and in a corner), `passes` (named piles — open floor, goal mouth, goal corner, side wall, arena corner, ramp, airborne lower, lower on a slope, box canyon — and 120 seeded ones: the report's `passes`, `pinned`, `pushed`, `walls` and every seat after) and `ride` (the rides and the seats after). Every number is a float64: a port gives every field exactly. The physics replay's contact pass (*Adopt and replay* step 3) is `physics-predict.json`'s case `contact policy in the replay` (its `contactPolicy`: a declaration and the movement preset's params; three other seats piling up beside a crate, the room's pass on them every tick, the client's on each replayed tick).
1273
+ `packages/netcode-bench/conformance/authority/contact-policy.json`, printed by `contact-policy.reference.mjs` beside it (written from *Contact policy*, importing nothing), holds the contact policy over a test yard (`world`: a floor, four walls, a 22° and a 63° ramp, a goal pocket with a crossbar, a box canyon, a corridor and a ring of walls at head height) and named `policies` (a declaration and its movement preset's params each): `config` (declarations resolved, or the refused path), `classify` (a pair's `kind`, `n`, `depth`, `upperIsB`), `pairs` (one pair step per policy, each with the outcome's `j`, `way` and the way's unit `w`, what the module heard, and both seats after; the `stand: …` rows hold the stand test: a centred and an off-centre landing, the edge band resting, parting and walked away from, past the footprint, anywhere with `center` null, and a box's heading frame), `yield` (side shares against a wall and in a corner), `passes` (named piles — open floor, goal mouth, goal corner, side wall, arena corner, ramp, airborne lower, lower on a slope, box canyon — and 120 seeded ones: the report's `passes`, `pinned`, `pushed`, `walls` and every seat after) and `ride` (the rides and the seats after) and `points` (its own world of 107 solids, large enough for a port's index: a point's `inside`, its push-out's first `face`, `deepest`, every face `heard` and the point `after`, walls under roofs among them, and `casts`). Every number is a float64: a port gives every field exactly. The physics replay's contact pass (*Adopt and replay* step 3) is `physics-predict.json`'s case `contact policy in the replay` (its `contactPolicy`: a declaration and the movement preset's params; three other seats piling up beside a crate, the room's pass on them every tick, the client's on each replayed tick).
1224
1274
 
1225
1275
  **Engines.** Unity, Unreal, Rust and Godot (a GDExtension, Godot 4.3+) predict on Genex Physics' C ABI: the same core as the WebAssembly module, built from the same commit, which each SDK vendors with a `PROVENANCE.json` pinning every file's sha256 (`packages/netcode-bench/test/authority/physics-capi-vendored.test.ts` holds the four copies to their pins and to each other). It is loaded at run time, never linked, and refused unless its ABI version is 1 and its `buildId` is the pinned build's; a missing or refused library leaves the client drawing as §17.10 says. Each engine's predictor passes every case of `physics-predict.json` on it (every adoption and client stateHash exact, every draw within the tolerance), and its join sends `physics@2` only with a core and prediction on. Its conformance driver advertises `physicsPredict` once the core loads, is the pinned build and passes the self-test scene, and predicts only when `authorityConnect` says `predictPhysics: true` (DRIVER.md).
1226
1276
 
@@ -1228,15 +1278,18 @@ A port builds its world from `physDesc` and `staticWorld`, runs every event thro
1228
1278
 
1229
1279
  A seat may move its inputs and its motion state off the WebSocket onto an unordered, unreliable *fast channel*: raw UDP for the engines, WebTransport datagrams in the browser. On the WebSocket one lost TCP segment holds every input and every patch behind it for a round trip or more (0.7–1.08 s measured on a home Wi-Fi link), and then about 126 inputs land at once. On the fast channel each datagram stands alone: the uplink carries each input in several datagrams, and a downlink snapshot that is lost is replaced by the next one. Everything else stays on the WebSocket (*the reliable channel*): join and auth, room state other than the motion records, game events, chat, admin, reconnection. The machine-readable copy is `src/authority/fast-transport.ts` (the tunables, `encodeUplink`/`decodeUplink`, `acceptUplink`, `uplinkFor`, `diffSnapshot`/`encodeSnapshot`, `decodeFragment`, `receiveFragment`). It is pure: the authority host, the TypeScript client and every engine compute the same bytes and the same decisions, held to `test/vectors/fast-transport.json`.
1230
1280
 
1231
- **Status.** The authority host serves the channel over raw UDP (`fast-endpoint.ts`). Four clients speak it over raw UDP, each passing the vectors with zero skips and a live check against a real room and endpoint on loopback: the TypeScript client in Node (`connectAuthority({ fast: openUdpFastSocket })`, *Client* below), Unreal's GenexCore (`AuthorityOptions::fast`, a socket opener; `FGenexAuthorityOptions::bFastChannel` in the plugin), the Rust SDK (`AuthorityOptions::fast(true)`) and the Unity core (`GenexAuthorityOptions.Fast`; not on WebGL, which has no sockets). Not yet in Godot: a Godot seat never sends `fast@1` and plays as a WebSocket seat. A browser reaches the same endpoint through a WebTransport relay (*WebTransport* below): the TypeScript client in the browser (`connectAuthority({ fast: openWebTransportFastSocket })`, `@genex-ai/multiplayer/authority/fast-webtransport`), live on loopback in headless Chromium through the relay. A browser without WebTransport, or a room whose worker has no relay, is a WebSocket seat as before.
1281
+ **Status.** The authority host serves the channel over raw UDP (`fast-endpoint.ts`). Four clients speak it over raw UDP, **on by default**, each passing the vectors with zero skips and a live check against a real room and endpoint on loopback: the TypeScript client in Node (`connectAuthority` with no option; `fast: false` is off, an opener replaces the socket), Unreal's plugin (`FGenexAuthorityOptions::bFastChannel`, default true; GenexCore's `AuthorityOptions::fast` is the socket opener it gives), the Rust SDK (`AuthorityOptions::fast`, default true) and the Unity core (`GenexAuthorityOptions.Fast`, default true; a WebGL player has no sockets: it claims `fast@1` only when the game sets `FastWebTransport`, by WebTransport). A game can turn it off; the WebSocket stays the automatic fallback. `authority-dev` serves it by default on loopback (its `--no-fast` is the switch), so a game tested locally runs on what players get. Not yet in Godot: a Godot seat never sends `fast@1` and plays as a WebSocket seat. A browser reaches the same endpoint through a WebTransport relay (*WebTransport* below): the TypeScript client in the browser, by default (`connectAuthority`, `@genex-ai/multiplayer/authority/fast-webtransport`), live on loopback in headless Chromium through the relay, and a Unity WebGL player (`Runtime/WebGL/GenexFastWeb.jslib`, `FastWeb.cs`, the same rules; held to a stand-in by `fast-unity-webgl.test.ts` and the `wt` vectors, never yet run in a player). A browser without WebTransport, or a room whose worker has no relay, is a WebSocket seat as before.
1282
+
1283
+ **The transport line.** Nobody should be on the WebSocket without knowing, so every client says one line per socket once its transport is known, one more if it falls back, and again when it returns, in the same words in every port (`fast-line.ts`, `transportLine` in the vectors): `genex transport: udp fast@1 <url>` after the first snapshot; `genex transport: websocket, <reason>: <why>` where `<reason>` is `off` (the game turned it off), `no_udp` (this runtime has no socket), `not_offered` (no binding within `FAST_OFFER_WAIT_MS`, 1 s, of the socket's first state) or a fallback's reason (`unreachable`, `handshake`, `stall`, `server`). It goes to stderr in Node and Rust (a tool's stdout may be its protocol), `LogGenex` in Unreal and the SDK's logger in Unity, and is readable as `session.fast.line`, `GetTransportLine()`, `GenexFastStatus.Line`, `transport_line()`. Godot has no fast channel: it says `genex transport: websocket, not_ported: this engine has no fast channel yet` on each socket.
1232
1284
 
1233
1285
  **Capability.** A client that implements this section sends `fast@1` in `caps` beside `tick@1` (every slot carries the input's tick). A room that offers the channel answers a `fast@1` join with the reliable message `__fast` `{ sid, key, url }` (`FastMessage.Bind`; reserved, so never a game event): `sid` a uint32 that names the seat on the channel, `key` 8 random bytes as 16 hex digits (`FAST_KEY_BYTES`), minted per socket, and `url` the endpoint (`udp://host:port` or an `https://` WebTransport URL). A room that does not offer it sends nothing and the seat stays a WebSocket seat; a client must never assume the channel before `__fast` arrives.
1234
1286
 
1235
- **Encoding.** Little-endian. Every datagram starts with `version` uint8 (`FAST_VERSION` = 1) and `kind` uint8 (`FastKind`: 1 uplink, 2 snapshot fragment). No datagram is longer than `FAST_MAX_DATAGRAM_BYTES` (1200: the IPv6 minimum MTU 1280 less IP, UDP and QUIC headers; WebTransport datagrams fit it on every path). A receiver drops, with no side effect at all, a datagram that is longer, shorter than its header, of another version or kind, that has bytes left over or that breaks any rule below (`FastFault` names each reason).
1287
+ **Encoding.** Little-endian. Every datagram starts with `version` uint8 (`FAST_VERSION` = 1) and `kind` uint8 (`FastKind`: 1 uplink, 2 snapshot fragment). No datagram is longer than `FAST_MAX_DATAGRAM_BYTES` (1200: the IPv6 minimum MTU 1280 less IP, UDP and QUIC headers; WebTransport datagrams fit it on every path). The largest is 1228 bytes of IPv4 or 1248 of IPv6 on the wire, within IPv6's 1280 minimum, so no IPv6 path fragments it; an IPv4 path under 1228 that drops rather than fragments carries a small snapshot and loses a large one, and its seat ends on the WebSocket with no input lost (*Return to the channel*). A receiver drops, with no side effect at all, a datagram that is longer, shorter than its header, of another version or kind, that has bytes left over or that breaks any rule below (`FastFault` names each reason).
1236
1288
 
1237
- **Session binding.** Every uplink datagram carries `sid` and `key`, and the server checks both against the seat's binding before it reads anything else: a wrong `sid` or `key` is dropped (`session`, `key`). The first datagram that passes binds the seat's source address (on UDP; a WebTransport session is its own binding), and a later one that passes from another address rebinds it (a NAT rebinding), so the key, never the address, is the credential. The key travels only on the reliable channel (TLS) and in the seat's own datagrams; a reconnect mints a new one and the old one is refused from then on.
1289
+ **Session binding.** Every uplink datagram carries `sid` and `key`, and the server checks both against the seat's binding before it reads anything else: a wrong `sid` or `key` is dropped (`session`, `key`). So the key, never the address, is the credential for a seat's inputs, from any address. The key travels only on the reliable channel (TLS) and in the seat's own datagrams; a reconnect mints a new one and the old one is refused from then on.
1290
+ **Return path** (authority-host `fast-endpoint.ts`, on the model of QUIC's path validation, RFC 9000 §8.2, and WebRTC's consent freshness, RFC 7675). A key proves who may feed a seat, not where its snapshots may go: a key holder could otherwise name someone else's address and aim a seat's snapshots at it. So snapshots go only to an address that has shown it reads them. An uplink that passes from an address that is not the seat's sends that address a *challenge*: one fragment, 33 bytes, a snapshot with no records whose id is a nonce (the seat's newest snapshot id plus a random 2^16 to 2^16 + 2^24), at most one per `FAST_PATH_CHALLENGE_GAP_MS` (50 ms) a seat. Every client, shipped ones too, applies it and sends its id as `ackSnap` in its next uplink, and that uplink from that address is the proof: the seat's snapshots move there, numbered on above the nonce. No wire change, no client change. A challenge repeats its base's tick and records, so it moves nothing: its base is the newest snapshot sent, then, attempts alternating, the newest acknowledged (a client whose old path died holds that one), and with neither an empty keyframe at the last patch's tick. Until an address validates the seat's snapshots keep going to the one it had, and its inputs from the new one are taken as usual, so a move loses no input. A first bind is the same move from no address: the channel is one round trip later than the first datagram, and the WebSocket keeps a physics room's motion until a snapshot that carries it is acknowledged. An `ackSnap` past every snapshot of the seat and no nonce it was sent, from an unproven address, is a guess: after `FAST_PATH_GUESSES` (8), that seat is sent no challenge for `FAST_PATH_LOCK_MS` (10 s). A bound address whose uplink falls silent for `FAST_CONSENT_MS` (10 s) stops getting snapshots until its next uplink, which needs no new proof. A nonce spends about 2^23 of the 2^32 ids, so a binding holds about 500 moves; past `2^32 − 2^25` no challenge is sent and the seat falls back. `stats().path` counts challenges, validations, guesses, locks and lapses. A WebTransport relay session is one more source address. Measured on loopback (`test/fast-rebind.test.ts`, real client, a NAT rig that moves the source port): longest gap between snapshots at a rebinding 52 to 69 ms (33 ms is the patch interval), seat on UDP, every input once in order; a 1.5 s outage with a new address and a longer path, seat on UDP, a gap of 151 ms after it.
1238
1291
 
1239
- **Endpoint guards** (authority-host `fast-endpoint.ts`). Before a seat reads a datagram, the server's UDP endpoint checks, cheapest first and with no side effect: the operator's switch, the size (at least the header through the key, at most `FAST_MAX_DATAGRAM_BYTES`), the version and kind bytes (an uplink), a token bucket per source address (`FAST_ADDRESS_DATAGRAMS_PER_S`, eight seats' worth for a carrier NAT; the table holds `FAST_ADDRESSES_TRACKED` sources, the least recently seen giving way), the sid, the key in constant time, then a token bucket per seat (`FAST_SEAT_DATAGRAMS_PER_S`, four datagrams a tick at 120 Hz). A wrong key spends no seat tokens, so a flood naming a sid cannot starve its holder. Each drop is counted by its `FastDrop` reason (`disabled`, `size`, `header`, `address_rate`, `unknown_sid`, `key`, `seat_rate`, and `refused` for a keyed datagram the seat's own checks drop). Nothing is sent to an address before a datagram from it passed its seat's key, so the endpoint never reflects a stranger's datagram. The operator's switch rides every admission grant and usage answer (`fast`): a grant with it false (or without it) offers no seat of that room the channel, and off on an answer the endpoint drops everything and sends nothing, so each fast seat falls back by the rule below; on again, bound seats are heard.
1292
+ **Endpoint guards** (authority-host `fast-endpoint.ts`). Before a seat reads a datagram, the server's UDP endpoint checks, cheapest first and with no side effect: the operator's switch, the size (at least the header through the key, at most `FAST_MAX_DATAGRAM_BYTES`), the version and kind bytes (an uplink), a token bucket per source address, an IPv6 source's being its /64, one subscriber's prefix (`FAST_ADDRESS_DATAGRAMS_PER_S`, eight seats' worth for a carrier NAT; the table holds `FAST_ADDRESSES_TRACKED` sources, the least recently seen giving way), the sid, the key in constant time, then a token bucket per seat (`FAST_SEAT_DATAGRAMS_PER_S`, four datagrams a tick at 120 Hz). A wrong key spends no seat tokens, so a flood naming a sid cannot starve its holder. Each drop is counted by its `FastDrop` reason (`disabled`, `size`, `header`, `address_rate`, `unknown_sid`, `key`, `seat_rate`, and `refused` for a keyed datagram the seat's own checks drop). Nothing is sent to an address before a datagram from it passed its seat's key, and then only the challenge until it validates (*Return path*), so the endpoint never reflects a datagram. The operator's switch rides every admission grant and usage answer (`fast`): a grant with it false (or without it) offers no seat of that room the channel, and off on an answer the endpoint drops everything and sends nothing, so each fast seat falls back by the rule below; on again, validated seats are heard.
1240
1293
 
1241
1294
  **Uplink datagram** (`kind` 1), 29 bytes of header:
1242
1295
 
@@ -1255,25 +1308,28 @@ Then `actions` held inputs, ascending seq, each `seq` uint32, `tick` uint16, `re
1255
1308
 
1256
1309
  **Render-time stamp.** Lag compensation (§17.5) judges a FIRE input at the render time its sender drew the world at; on the WebSocket that stamp rides each input frame (Colyseus's TIMED prefix: `round(serverNow) − round(renderDelay + rtt/2)`, whole ms, 0 before the clock syncs) and the room reads it as `rewind.lastSeenBy(sid)`, the stamp of the seat's last consumed WebSocket input. A fast seat's inputs reach the server by datagram, so each carries its own: an action's `render`, or a slot's `back` against the header's `render` (a slot more than 254 ms behind the newest is sent unstamped: a window spans 170 ms, so only an input from before a long hitch is). Every fast client stamps each input, as it sends it, with the value its WebSocket frame would carry (TypeScript: the input handle's own stamp; Unreal, Rust, Unity: `renderStamp` on their room clock and the handle's render delay, held to the `renderStamp` vectors); Godot has no fast channel (*Status*). `acceptUplink` yields each accepted input with its render time (a filled input takes the render time of the input after its gap); the room judges a fast seat's lag-compensated input at it (`rewind.at(render)`), and at `lastSeenBy` only an unstamped one. Before, a fast seat's FIRE was judged at its last WebSocket stamp: TCP-late for a client that keeps its input frames on the socket (Unreal, Rust), and frozen at the binding for one that keeps them off (TypeScript, Unity), so clamped to `MAX_REWIND_MS` (500 ms) behind. One stamp a datagram would not do: a datagram carries the window's older inputs again, and a recovered input (its first datagram lost), or a frame of catch-up inputs after a hitch, was drawn at an earlier render time than the datagram that delivers it.
1257
1310
 
1311
+ **Keepalive.** A seat with nothing to send (a lobby, a spectator, a background tab) still needs its binding and its NAT mapping, and its snapshots acknowledged. From a host whose binding carries `rev` ≥ `FAST_BIND_REV` (2) a client sends an *empty uplink* when nothing else went: the 29-byte header with `count`, `fresh`, `actions`, `newest` and `render` all 0, its `ackSnap` its whole message (any other `count` 0 stays the fault it was, and an older host, which refuses it, gets none). It goes at once when a binding opens, every `FAST_KEEPALIVE_BIND_MS` (50) until the first snapshot, every `FAST_KEEPALIVE_MS` (250) after; on a timer as well as each frame, so it goes on while frames do not run; at once on a challenge (*Return path*: an id `FAST_CHALLENGE_JUMP`, 65,536, past the newest); and as a snapshot changing `FAST_ACK_EAGER_RECORDS` (32) or more lands, at most every `FAST_ACK_EAGER_GAP_MS` (20), so a seat with no input in a big room gets small deltas. A seat sending inputs each frame sends none of these. The interval: NATs need far less, as RFC 9308 §3.2 says to assume a binding can expire after 30 s and Richter et al. (IMC 2016) measured 74% of NATs at 1 minute or less, from 10 to 200 s (35 s median non-cellular, 65 s cellular), so 5 s would hold every one with a loss to spare; the host's 32 snapshots of history need an ack every 266 ms at 120 Hz or deltas turn to keyframes. Measured on loopback UDP (`test/measure-keepalive.ts`, on the wire, a seat with no input beside a walker, 30 Hz patches): at 250 ms 223 B/s up and 3,223 B/s down; at 1 s 57 up, 3,246 down; at 5 s 10 up, 3,995 down (24% more, keyframes); in a room of 1,016 moving entities the eager ack takes the seat from 1.07 MB/s, its budget, to 379 KB/s down for 1.6 KB/s up. Past `FAST_CONSENT_MS` with no uplink a seat gets no snapshots (*Return path*). A client also takes off both deadlines (*Fallback*) any time past `FAST_FRAME_GAP_MS` (250) that no frame ran: a tab back from the background or an app back from suspension has not seen its channel die, and its first frame finds it live; a channel still dead falls back after the deadline's worth of frames.
1258
1312
  **Wire seq.** The seq is the input's client sequence (§17.9's `cseq`): a fast seat numbers its inputs from `seqBase + 1`, one per input, and never reuses one. It is a uint32 that never wraps: a seat that would pass 2^32 − 1 (414 days at 120 Hz) reconnects, so a seq at or below the last accepted one is always stale, including 0 after 2^32 − 1.
1259
1313
 
1260
1314
  **Redundancy and one-shot inputs.** A client sends one datagram whenever it sends inputs (each frame), whose window is its last `inputRedundancyTicks(tickHz)` inputs (`INPUT_REDUNDANCY_MS` = 170 ms of them, at least 3 and at most 32: 21 at 120 Hz, 11 at 60, 6 at 30). An input with a *press* (a button bit its previous input lacked) that has slid out of the window and that the server has not yet acknowledged (`inputAck` below) rides as an action until it is, the oldest `FAST_MAX_ACTIONS` of them (`uplinkFor`). The server takes every slot oldest first, so the first datagram it accepts past a press carries that press: a jump or a shot is never lost, only late.
1261
1315
 
1262
1316
  **Server dedup** (`acceptUplink`, per seat: the last accepted seq and input, the newest `ackSnap`). The datagram is checked whole before any of it is used: a `newest` more than `FAST_SEQ_AHEAD_MS` (5 s) of ticks past the last accepted seq is dropped whole, with no side effect (a seq that far ahead would make every honest later input stale). Then each held input and slot, ascending, goes through Colyseus `InputBuffer.accept(seq)`'s rule: a seq at or below the last accepted is a duplicate or stale and is dropped with no side effect; a greater one is accepted. A seq past the last accepted plus one leaves a *gap* of inputs no datagram the server got carried: up to `gapFillMax(tickHz)` of them (`FAST_GAP_FILL_MS` = 250 ms) are *filled*, each a copy of the last accepted input with no press of its own (the buttons held as they were), and accepted before it; a longer gap is *skipped* (its seqs are consumed and never stepped; the client was gone longer than a repeat would stand in). An accepted input is *recovered* when its seq is below the datagram's `newest − fresh + 1`: it reached the server by redundancy after the datagram that first sent it was lost. `ackSnap` moves the seat's snapshot ack forward only, and only to a snapshot the server sent.
1263
1317
 
1264
- **Downlink snapshot** (`kind` 2). Each seat gets its own snapshots: the room's *motion records* (below), delta-coded against the newest snapshot that seat acknowledged. A fragment has 29 bytes of header: `sid` uint32 · `snap` uint32 (the snapshot id, from 1, rising by at least one per snapshot) · `base` uint32 (the snapshot the delta is against; 0 for a keyframe) · `tick` uint32 (the server tick it was written at) · `inputAck` uint32 (the seat's last accepted input seq) · `flags` uint8 (bit 0 *keyframe*, set exactly when `base` is 0) · `frag` uint8 · `frags` uint8 (`frag < frags ≤ FAST_MAX_FRAGMENTS`, 32) · `sent` uint32 (the room clock, ms since room start, when the server wrote the snapshot: the `sNow` a WebSocket patch's TIMED prefix carries, §16.1), then that fragment's slice of the body. The body, split into slices of at most `FAST_MAX_DATAGRAM_BYTES − 29` bytes, is `removed` uint16 and that many record keys uint16, ascending, then `records` uint16 and that many records, ascending by key, each `key` uint16, `len` uint16 (at most `FAST_MAX_RECORD_BYTES`, 1024) and `len` bytes. A delta lists the records new or changed since `base` and the keys gone since it; a keyframe lists every record and removes nothing. The server keeps its last `FAST_SNAPSHOT_HISTORY` (32) snapshots per seat and writes a delta against the seat's `ackSnap` while it holds that snapshot, else a keyframe. A body over `FAST_MAX_FRAGMENTS` fragments is not sent on this channel.
1318
+ **Downlink snapshot** (`kind` 2). Each seat gets its own snapshots: the room's *motion records* (below), delta-coded against the newest snapshot that seat acknowledged. A fragment has 29 bytes of header: `sid` uint32 · `snap` uint32 (the snapshot id, from 1, rising by at least one per snapshot) · `base` uint32 (the snapshot the delta is against; 0 for a keyframe) · `tick` uint32 (the server tick it was written at) · `inputAck` uint32 (the seat's last accepted input seq) · `flags` uint8 (bit 0 *keyframe*, set exactly when `base` is 0) · `frag` uint8 · `frags` uint8 (`frag < frags ≤ FAST_MAX_FRAGMENTS`, 32) · `sent` uint32 (the room clock, ms since room start, when the server wrote the snapshot: the `sNow` a WebSocket patch's TIMED prefix carries, §16.1), then that fragment's slice of the body. The body, split into slices of at most `FAST_MAX_DATAGRAM_BYTES − 29` bytes, is `removed` uint16 and that many record keys uint16, ascending, then `records` uint16 and that many records, ascending by key, each `key` uint16, `len` uint16 (at most `FAST_MAX_RECORD_BYTES`, 1024) and `len` bytes. A delta lists the records new or changed since `base` and the keys gone since it; a keyframe lists every record and removes nothing. The server keeps its last `FAST_SNAPSHOT_HISTORY` (32) snapshots per seat and writes a delta against the seat's `ackSnap` while it holds that snapshot, else a keyframe.
1319
+ **Partial snapshots** (authority-host `fast-snapshots.ts`). Each seat has a byte budget, a token bucket at its share of the room's rate (§17.6 *The downlink budget*) with a burst of one snapshot of `FAST_MAX_FRAGMENTS` full fragments. A snapshot that fits both is written as above, byte for byte. One past either is never dropped: it goes as a partial snapshot, its body cut to what the budget holds (at least one fragment, so the seat's ack always flows and the channel never looks dead). It needs no wire change and every client applies it. The records it carries are chosen by weight times age: the seat's own record and its own entity always go, then the other seats, then each entity by how near yours it is (`motionWeights`), and each record passed over ages, so none starves. A record that changed and is passed over is listed under `removed` when the base holds it: the client's newest snapshot no longer carries it, its state keeps the last value and a predicting client keeps the body out of the keyframe it adopts (`motionKnown`, *Client* below), never adopting old motion as current. The next delta is written against what the client will hold, so what was passed over goes out later. The endpoint counts each snapshot by shape (`stats().snapshots`: `whole`, `partial`, `refused`, and `deferred` records), under `fast` on the metrics endpoint. Measured on loopback UDP (`test/fast-many.test.ts`): a seat joining a physics room of 1,016 moving entities, whose keyframe is about 90 KB, used to get nothing and fell back at the handshake deadline; it now stays on the channel with every entity moving and no gap between snapshots over 500 ms, and so does one held to 48 KiB/s for a whole take.
1265
1320
 
1266
- **Motion records** (`src/authority/fast-records.ts`, `encodeRecord`/`decodeRecord`). Each record is one entity's or one seat's: `kind` uint8 (`MotionRecordKind`: 1 entity, 2 seat), `idLen` uint8 and that many UTF-8 bytes of the entity id or seat session id (1 to 64 characters), a `mask` uint32 whose bit *i* says field *i* is present, then each present field in table order. An entity's fields, bit 0 up: `x` `y` `z` `vx` `vy` `vz` `yaw` `pitch` `duck` `spin` `fuel` float32; `epoch` `mstate` uint32; `qx` `qy` `qz` `qw` (orientation, [−1, 1]) and `lvx` `lvy` `lvz` `avx` `avy` `avz` (velocity, [−64, 64]) as uint16 codes of the schema's own quantize (clamp, span 2^16 − 2, half-up, so 0 is code 32767 and a fast seat decodes bit for bit what a WebSocket seat decodes); `sleep` (the bit alone); `rest` uint16. A seat's: `inMoveX` `inMoveY` `inMoveZ` `inYaw` `inPitch` float32; `inButtons` `inRep` `inStarve` `inBuf` `inAck` uint32, `inAck` being, in the receiving seat's own record only, its last input the server consumed (stepped or skipped) as of the snapshot, which its reconciler replays from (the header's `inputAck` is the last *accepted*, which trims actions). A field at its default (0, −0, false, never written) is left out and a field present at its default is a fault, so the bytes are canonical and a delta carries a record only when one of its fields changed; a field left out reads as its default. The entity's other fields (`kind`, `owner`, `hp`, `flags`, `data`, projectile and `cap` fields) and everything else in the state stay on the reliable channel. The server gives each live id a record key (uint16) of its own for the room; a client reads the id from the record, never from the key. A decoder drops, with its `FastFault`, a record of another kind, one that is truncated or has bytes left over, an empty, over-long or malformed id, an unknown mask bit, or a present field at its default, non-finite or past its span; a client leaves that id as it was.
1321
+ **Motion records** (`src/authority/fast-records.ts`, `encodeRecord`/`decodeRecord`). Each record is one entity's or one seat's: `kind` uint8 (`MotionRecordKind`: 1 entity, 2 seat), `idLen` uint8 and that many UTF-8 bytes of the entity id or seat session id (1 to 64 characters), a `mask` uint32 whose bit *i* says field *i* is present, then each present field in table order. An entity's fields, bit 0 up: `x` `y` `z` `vx` `vy` `vz` `yaw` `pitch` `duck` `spin` `fuel` float32; `epoch` `mstate` uint32; `qx` `qy` `qz` `qw` (orientation, [−1, 1]) and `lvx` `lvy` `lvz` `avx` `avy` `avz` (velocity, [−64, 64]) as uint16 codes of the schema's own quantize (clamp, span 2^16 − 2, half-up, so 0 is code 32767 and a fast seat decodes bit for bit what a WebSocket seat decodes); `sleep` (the bit alone); `rest` uint16. A seat's: `inMoveX` `inMoveY` `inMoveZ` `inYaw` `inPitch` float32; `inButtons` `inRep` `inStarve` `inBuf` `inAck` uint32, `inAck` being, in the receiving seat's own record only, its last input the server consumed (stepped or skipped) as of the snapshot, which its reconciler replays from (the header's `inputAck` is the last *accepted*, which trims actions). A field at its default (0, −0, false, never written) is left out and a field present at its default is a fault, so the bytes are canonical and a delta carries a record only when one of its fields changed; a field left out reads as its default. The entity's other fields (`kind`, `owner`, `hp`, `flags`, `data`, projectile and `cap` fields) and everything else in the state stay on the reliable channel. A projectile (an entity with `t0` > 0) has no record at all: its `x`..`vz` are its launch, read with `t0`, `g` and `src`, so the whole launch stays on the reliable channel and is never muted there (*Downlink*). Split across the two channels, a bounce's re-launch was drawn as the new flight from the old `t0` (or the old flight from the new one) until the other half landed: 7 to 38 units off on Unreal's projectile smoke over UDP. The server gives each live id a record key (uint16) of its own for the room; a client reads the id from the record, never from the key. A decoder drops, with its `FastFault`, a record of another kind, one that is truncated or has bytes left over, an empty, over-long or malformed id, an unknown mask bit, or a present field at its default, non-finite or past its span; a client leaves that id as it was.
1267
1322
 
1268
1323
  **Applying a snapshot** (`receiveFragment`, the client). A fragment whose `sid` is not the seat's is dropped. Fragments are gathered by `snap`; one whose header differs from its set's first (`base`, `tick`, `inputAck`, `flags`, `frags`, `sent`), or a duplicate, is dropped; at most `FAST_REASSEMBLY_SLOTS` (4) sets are gathered at once, the oldest giving way. A complete set is applied when its `snap` is newer than the newest applied and its base is a snapshot the client still holds (its last `FAST_SNAPSHOT_HISTORY` applied) or it is a keyframe; otherwise it is dropped, and so is a body that does not parse to the byte. Applying it copies the base's records, removes the removed keys, writes the records, and makes it the newest: its id is the next `ackSnap`, and its `inputAck` the ack `uplinkFor` trims actions by. Applying drops every incomplete set at or below the new snapshot: a lost fragment loses its snapshot, never the next one.
1269
1324
 
1270
- **WebTransport** (`apps/fast-relay`; `fast-webtransport.ts` the browser socket). A browser has no raw UDP, so a worker whose droplet runs the relay adds `wt` `{ url, hashes }` to every binding: `url` the relay's session for this worker, `https://<relay>/w/<n>` with `n` the worker's offset from port 2567, and `hashes` the SHA-256 (64 lowercase hex digits each, at most `FAST_WT_HASHES_MAX` = 4) of the certificates the relay may serve, for `serverCertificateHashes`. Every client but a browser ignores `wt`; a binding without one, or with one a browser cannot use (not `https://`, credentials, a query or fragment, a malformed hash), leaves a browser seat on the WebSocket. The browser opens the session with `requireUnreliable` and refuses one whose `reliability` is `reliable-only` (WebTransport over HTTP/2, a TCP stream); a refused or failed session is a fallback at once (`failed()` on the socket, reason `unreachable`), with no deadline to wait out, and so is one whose `datagrams.maxDatagramSize` (the browser's outgoing limit: it sends only uplinks) is below the seat's largest uplink, `uplinkMaxBytes(tickHz)` = 29 + 8 × 34 + window × 28 bytes (609 at 60 Hz, 889 at 120, 1197 at 240; Chromium 134 reports 1024). Datagrams queued while the session connects (QUIC and HTTP/3 take about two round trips) go when it is ready, the newest 32; the handshake deadline counts from the first queued one. The relay is not an open proxy: its upstreams are a fixed allowlist (`/w/<n>` to worker `n`'s UDP endpoint), any other path is answered 404 before any socket opens, and each session is one connected UDP socket to its worker, so only that worker's datagrams reach the browser. It forwards every datagram verbatim and checks only what needs no key: the size (an uplink at least its header through the key, any datagram at most `FAST_MAX_DATAGRAM_BYTES`), the version and kind bytes (uplinks one way, snapshot fragments the other), a token bucket per session (the worker's seat rate, 480 a second, burst 240) and a cap on sessions per client address (16) and in all (4096); the worker checks everything else, so to the worker a browser seat is one more UDP source. The worker keys its per-address bucket by address and port for the relay's addresses (`FAST_RELAY_SOURCES`), one relay session per browser. Its QUIC starts at a 1232-byte UDP payload (the IPv6 minimum MTU less the IPv6 and UDP headers), so a whole fast@1 datagram fits from the first packet, and a session whose datagrams cannot hold one is closed. Certificates: ECDSA P-256, minted in the relay's memory, valid 13 days; every 6 days the next becomes the one served and a new next is minted, and the relay publishes `{ "v": 1, "hashes": [current, next] }` to a file the workers read at most once a second (`FAST_WT_HASHES_FILE`), so a binding sent just before a rotation still matches. The relay's key never enters a worker. Worker configuration: `FAST_WT_ORIGIN` (`https://host[:port]`; unset, no `wt`), `FAST_WT_HASHES_FILE`, `FAST_RELAY_SOURCES`.
1325
+ **WebTransport** (`apps/fast-relay`; `fast-webtransport.ts` the browser socket). A browser has no raw UDP, so a worker whose droplet runs the relay adds `wt` `{ url, hashes }` to every binding: `url` the relay's session for this worker, `https://<relay>/w/<n>` with `n` the worker's offset from port 2567, and `hashes` the SHA-256 (64 lowercase hex digits each, at most `FAST_WT_HASHES_MAX` = 4) of the certificates the relay may serve, for `serverCertificateHashes`. Every client but a browser ignores `wt`; a binding without one, or with one a browser cannot use (not `https://`, credentials, a query or fragment, a malformed hash), leaves a browser seat on the WebSocket (reason `no-relay`). **A browser seat uses this by default**: `connectAuthority` with no `fast` option opens WebTransport wherever the page has it, and `fast: false` turns it off. A browser without `WebTransport` (or a page that is not a secure context) never sends `fast@1`, so it plays exactly as a WebSocket seat, with `fallback` reading `no-webtransport`. The browser opens the session with `requireUnreliable` and refuses one whose `reliability` is `reliable-only` (WebTransport over HTTP/2, a TCP stream); a refused or failed session is a fallback at once (`failed()` on the socket, reason `unreachable`), with no deadline to wait out, and so is one that offers no way to write datagrams (Chromium has `datagrams.writable`, Safari 26 only `datagrams.createWritable()`; the socket takes either), and one whose `datagrams.maxDatagramSize` (the browser's outgoing limit: it sends only uplinks) is below the seat's largest uplink, `uplinkMaxBytes(tickHz)` = 29 + 8 × 34 + window × 28 bytes (609 at 60 Hz, 889 at 120, 1197 at 240; Chromium 134 reports 1024). Datagrams queued while the session connects (QUIC and HTTP/3 take two or three round trips: Safari 26.6.2 three, 1523 ms at a 500 ms round trip) go when it is ready, the newest 32. The handshake runs twice on such a socket: from the first queued datagram to the session opening, `FAST_HANDSHAKE_MS` or `FAST_OPEN_ROUND_TRIPS` (4) of the seat's round trip (one WebSocket ping at the binding), whichever is longer, so a blocked network ends there with reason `handshake`; and from the session opening to the first snapshot, `FAST_HANDSHAKE_MS`. A long route's seat is not sent back for its session's own handshake, and a short route's deadline is what it always was. The relay is not an open proxy: its upstreams are a fixed allowlist (`/w/<n>` to worker `n`'s UDP endpoint), any other path is answered 404 before any socket opens, and each session is one connected UDP socket to its worker, so only that worker's datagrams reach the browser. It forwards every datagram verbatim and checks only what needs no key: the size (an uplink at least its header through the key, any datagram at most `FAST_MAX_DATAGRAM_BYTES`), the version and kind bytes (uplinks one way, snapshot fragments the other), a token bucket per session (the worker's seat rate, 480 a second, burst 240) and a cap on sessions per client address (16) and in all (4096); the worker checks everything else, so to the worker a browser seat is one more UDP source. The worker keys its per-address bucket by address and port for the relay's addresses (`FAST_RELAY_SOURCES`), one relay session per browser. Its QUIC starts at a 1232-byte UDP payload (the IPv6 minimum MTU less the IPv6 and UDP headers), so a whole fast@1 datagram fits from the first packet, and a session whose datagrams cannot hold one is closed. Certificates: ECDSA P-256, minted in the relay's memory, valid 13 days; every 6 days the next becomes the one served and a new next is minted, and the relay publishes `{ "v": 1, "hashes": [current, next] }` to a file the workers read at most once a second (`FAST_WT_HASHES_FILE`), so a binding sent just before a rotation still matches. The relay's key never enters a worker. Worker configuration: `FAST_WT_ORIGIN` (`https://host[:port]`; unset, no `wt`), `FAST_WT_HASHES_FILE`, `FAST_RELAY_SOURCES`.
1271
1326
 
1272
- **Fallback and mixed rooms.** A room mixes fast and WebSocket seats; a WebSocket seat is exactly as before (§17.3, §17.11). A fast-capable client falls back to the WebSocket for the rest of the socket when no snapshot arrives within `FAST_HANDSHAKE_MS` (1 s) of its first datagram, or none for `FAST_STALL_FALLBACK_MS` (2 s) later: it sends the reliable message `__fast:off` `{ last }` (`FastMessage.Off`, the last seq it sent) and sends its next input by `room.input()`. The server drops any WebSocket input the seat sent before the message, counts the WebSocket inputs on from `max(last, accepted)` (the first is `max(last, accepted) + 1`, so the client sequence never repeats), and feeds what it accepted by datagram first. A datagram the client sent before its message can reach the server after it (another path, or a socket read later: the Unreal `off` run lost one input of 117 so), so until the seat has accepted through `max(last, accepted)` or `FAST_OFF_GRACE_MS` (100 ms, `authority-host/src/fast-seat.ts`) has passed since the message, the server still reads the seat's datagrams, taking only their inputs through that seq, and steps none of its WebSocket inputs (a starved tick meanwhile is an ordinary starve); then it stops reading the seat's datagrams (its binding is closed), and seqs sent by datagram that never arrived are consumed without a step. A `last` that is not a uint32 within `FAST_SEQ_AHEAD_MS` of the accepted seq, a second `__fast:off`, or one from a WebSocket seat changes nothing. On the authority host the client sequence of a consumed input is today `seqBase + consumedCount` (`room.ts` 606–631, the reliable implicit count); a fast seat's is the entry's wire seq, which a fill keeps contiguous and only a skip moves past the count, so the repeat ledger (`repeat@1`) and every `ackSeq` key on seq, not on count. A fast seat's motion records reach it by snapshot (*Downlink* below), and the client keeps the snapshot's (*Client* below).
1327
+ **Fallback and mixed rooms.** A room mixes fast and WebSocket seats; a WebSocket seat is exactly as before (§17.3, §17.11). A fast-capable client falls back to the WebSocket when no snapshot arrives within `FAST_HANDSHAKE_MS` (1 s) of its first datagram, or none for `FAST_STALL_FALLBACK_MS` (2 s) later: it sends the reliable message `__fast:off` `{ last }` (`FastMessage.Off`, the last seq it sent) and sends its next input by `room.input()`. The server drops any WebSocket input the seat sent before the message, counts the WebSocket inputs on from `max(last, accepted)` (the first is `max(last, accepted) + 1`, so the client sequence never repeats), and feeds what it accepted by datagram first. A datagram the client sent before its message can reach the server after it (another path, or a socket read later: the Unreal `off` run lost one input of 117 so), so until the seat has accepted through `max(last, accepted)` or `FAST_OFF_GRACE_MS` (100 ms, `authority-host/src/fast-seat.ts`) has passed since the message, the server still reads the seat's datagrams, taking only their inputs through that seq, and steps none of its WebSocket inputs (a starved tick meanwhile is an ordinary starve); then it stops reading the seat's datagrams (its binding is closed), and seqs sent by datagram that never arrived are consumed without a step. A `last` that is not a uint32 within `FAST_SEQ_AHEAD_MS` of the accepted seq, a second `__fast:off`, or one from a WebSocket seat changes nothing. On the authority host the client sequence of a consumed input is today `seqBase + consumedCount` (`room.ts` 606–631, the reliable implicit count); a fast seat's is the entry's wire seq, which a fill keeps contiguous and only a skip moves past the count, so the repeat ledger (`repeat@1`) and every `ackSeq` key on seq, not on count. A fast seat's motion records reach it by snapshot (*Downlink* below), and the client keeps the snapshot's (*Client* below).
1273
1328
 
1329
+ **Return to the channel** (`fast-session.ts`, authority-host `room.ts`). A fallback is not for the rest of the socket: a client that sends `fast-return@1` beside `fast@1`, in a room whose binding says `rev` ≥ 2, comes back by itself. It never moves until the path is proven, at the start too, so a network that blocks UDP costs it nothing. (1) The server offers such a seat `__fast` with `probe: true`, at join and at each reconnect, and answers its `__fast:again` `{}` the same way: a fresh `sid` and `key`, at most one per `FAST_AGAIN_MIN_MS` (500 ms) a seat, none while the seat is live or the switch is off; once the switch is on it offers one, unasked, to a seat that has none. An older client's first binding is as it always was. (2) The WebSocket carries everything while the seat proves the path: it sends empty uplinks, its address is challenged (*Return path*) and it applies snapshots, writing none of them anywhere, until it holds `FAST_RETURN_PROOF` (3). Anything it sends by datagram before step 3 waits on the server. (3) At the third it swaps, at one instant: `__fast:on` `{ last }` (the last seq it sent by WebSocket) goes on the socket after its last input frame, its next input goes by datagram as `last + 1`, and that snapshot is written whole, as a first one is. (4) The server reads `__fast:on` after every WebSocket input it follows, because TCP keeps their order: what its buffer still holds goes first, numbered as it would have been, then the parked datagrams from `max(last, received)` (at most 512 parked, a longer stall being the gap rule's), so no input is lost or stepped twice, however long TCP held the message. A WebSocket input stepped this way is judged at the seat's newest stamp. A physics room sends motion by WebSocket until the seat is live on the new binding and has acknowledged a snapshot that carries motion. A `__fast:on` that is malformed, past `FAST_SEQ_AHEAD_MS` of what was received or with no binding being proven changes nothing. (5) A socket that has to open (WebTransport) gets the same deadline on a fresh binding as on any: `FAST_HANDSHAKE_MS` or `FAST_OPEN_ROUND_TRIPS` of the seat's round trips, whichever is longer, to open, then `FAST_HANDSHAKE_MS` to the first snapshot; none of it costs an input, as the WebSocket has them. Measured on a session taking 1.5 s to open at 500 ms round trip: 86 and 87 of about 508 inputs lost with the seat's inputs on the datagrams while it opened (only the newest 32 survive the wait), 0 of 511 and 512 here. A proof that misses a deadline (*Fallback*) goes back: `__fast:off` `{ last }` closes that binding, the numbering unchanged, and `fallback` says why the seat is on the WebSocket. The retries: `fastRetryDelayMs(attempt, unit)`, doubling from `FAST_RETRY_BASE_MS` (1 s) to `FAST_RETRY_MAX_MS` (60 s), half of it random, forever; an ask that is answered does not climb, and a channel held for `FAST_RETURN_STABLE_MS` (30 s) starts over from 0, so a flapping path backs off; after the server's own `__fast:off` the first is 4 to 8 s on. An older host's binding has no `rev`: its seat sends no empty uplink and no `__fast:again` (Colyseus closes a socket over a message with no handler), and falls back as it always did. `status.fallbacks` and `status.returns` count. Measured on loopback UDP through the NAT rig (`test/fast-return.test.ts`): snapshots cut for 3 s, back on UDP within 3 s, every input once in order, 3 cuts in a row the same; on UDP 135 to 151 ms after the seat appears at 80 ms round trip; UDP blocked from the first datagram, 0 of 139 inputs lost, against 56 of 138 before (the first ones to reach the module were seq 57). Both ways cut, the inputs sent by datagram into the cut are still skipped by the gap rule (119 of 391): they are 2 s old, and a late step is worse.
1274
1330
  **Downlink.** In a physics room (§17.10: every client has a view) the state's entities are `AuthorityPhysicsEntity`, `AuthorityEntity`'s fields in its order under its name (decoders and the handshake's type description are unchanged), whose motion fields (an entity record's, *Motion records* below) carry the Colyseus view tag `MOTION_VIEW_TAG`; every entity enters a view with it. Once a fast seat has acknowledged a snapshot (`ackSnap` past 0) while its binding is open, each entity its view introduced in an earlier patch leaves its view's tag, silently (no delete reaches the client): its WebSocket carries that entity's other fields, the seat echoes and the root's `tick` and `keyframe`, never its motion. When the channel closes (the seat's `__fast:off` or the server's), or the socket reconnects, every such entity's motion is sent again in full in the next patch (or the reconnect's first state). A room without physics writes motion to every seat's WebSocket as before. **Liveness.** A datagram that passed a seat's binding proves its client alive: it answers the WebSocket transport's heartbeat for the seat's socket (uWebSockets.js, the production transport, closes a socket only after `idleTimeout`, 60 s, without a frame; every server on Colyseus's ws transport, the dev server, the bench's rooms and parks, runs its heartbeat at that deadline, 4 unanswered 15 s pings, `WS_TRANSPORT_HEARTBEAT`, where its default of 2 unanswered 3 s pings closed seats production keeps through a long route's TCP stall), so a TCP stall the datagrams outlive never closes a playing seat. A fast seat whose socket did drop keeps its snapshots, of the view it dropped with, through the reconnection grace, while its datagrams still feed the module.
1275
1331
 
1276
- **Client** (`src/authority/fast-client.ts`, `fast-session.ts`; `fast-udp.ts` the Node socket). A client with a socket opener sends `fast@1`. It takes `__fast` once the socket's state is decoded (its `seqBase` is the player's), and an unusable one (not a uint32 sid, a 16-hex-digit key and a url, or a url it cannot open: in Node anything but `udp://host:port` with a port in 1..65535, an IPv6 host in brackets, and no credentials, path, query or fragment) is a fallback at once. Its wire seq is `seqBase` plus the input's count on the socket. The inputs it sent by WebSocket before the binding arrived were dropped by the server unstepped: its first datagrams carry them again, and whenever more new inputs are due than one window holds it sends one datagram per window's worth, oldest first, so none is stepped as a fill. While the channel is live the server drops its WebSocket inputs unstepped; its ack is its own seat record's `inAck`; and it writes each applied snapshot into its state as a patch would (each record's fields through the schema, a field the record leaves at its default unwritten where the state never wrote one, with the state's `tick` and, in a predicting room, which patches only on keyframes, `keyframe` set to the snapshot's `tick`), then runs the state's change listeners, so prediction, reconcile and interpolation never see which channel delivered a state. A WebSocket patch that would write one of those fields for an id the channel holds is put back before any listener runs: a patch held behind a lost TCP segment never moves an entity or the tick back. The client keeps the WebSocket's latest value of each field it holds, and decodes each patch against those values (Colyseus sends a field only when it changes and reports a decoded field only when it differs from the one held). Motion comes by snapshot and the entity list by WebSocket, so an entity can be listed while the newest snapshot does not carry its record: it left your view and the WebSocket's removal is still on its way, or it came back into view by WebSocket first. Its fields then hold another keyframe's motion, so a predicting client keeps its body out of the keyframe it adopts, as one out of view (§17.11 *The client world*), until a snapshot carries it again. Every fast client with body prediction does (TypeScript, Unreal, Rust, Unity), held to the `motionKnown` vectors. Adopted as part of the keyframe, a body a keyframe behind failed the canary's exact comparison: on Mechanism Hall at `near-wifi`, where every radio episode releases snapshots and patches in separate bursts, prediction turned `diverged` for 7 of 28 fast seats a batch and for none on WebSocket. At a fallback, every held field takes the WebSocket's latest value in one patch, so no field keeps a snapshot's value the server has since moved on from (a seat that stopped while the snapshots were cut is drawn where it stopped); in a physics room an entity's motion instead keeps the channel's values until the server's full resend lands (*Downlink*: its WebSocket values date from when the channel went live), so nothing is drawn back where it was then. Each snapshot is a clock sample at its `sent`, with the round trip of the input its `inAck` newly acknowledges (timed from that input's send, which the client keeps for its newest `FAST_SEQ_AHEAD_MS` of inputs: the server consumes an input well after it received it), so a fast seat's clock reads what a WebSocket seat's does; the tick's nominal instant would read up to a step early, by however late the server ran that step. After a fallback the WebSocket ack counts that socket's WebSocket inputs (the dropped ones too), so the client's ack is its count at the fallback plus the WebSocket ack past the inputs it sent by WebSocket before it. A client may keep sending its input frames while live (Unreal and Rust, whose core's input handle exposes neither baseline below) or keep them off the socket (TypeScript and Unity, so a TCP stall holds no inputs); either way an input frame is a delta against the last frame the client *encoded* (the fields that changed since) and its lag-compensation stamp a delta against the last stamp it wrote, so a frame kept off the socket moves both baselines past what the server decoded. A client that keeps frames off must therefore, at the fallback, send its next frame whole (every field) and its next stamp as a delta against the last stamp the server got (the one before the binding); otherwise each input it sends after the fallback is stepped with the fields the server last decoded (a held axis read as 0) and judged seconds in the past. The server may end the channel with `__fast:off` `{}`; the client answers with its own `__fast:off { last }` and falls back as above (no server sends it yet). A seat that falls back before its first datagram loses the inputs it sent by WebSocket before the binding.
1332
+ **Client** (`src/authority/fast-client.ts`, `fast-session.ts`; `fast-udp.ts` the Node socket, `fast-default.ts` finding it at run time so no browser bundle links it, `fast-webtransport.ts` the browser's). A client with a socket opener sends `fast@1`: in Node that is every client that did not say `fast: false`; in a browser, WebTransport unless the game said `fast: false`. `session.fast` says `live`, `snapshots` and `fallback`, why the seat is on the WebSocket: `no-webtransport` (the page has none: it never asked), `no-relay` (a binding with no usable `wt`), `not-offered` (no binding within `FAST_HANDSHAKE_MS` of its first frame: a room or server with no channel; a binding that comes later still opens), `unreachable` (a malformed binding, a socket that cannot open, or a session that failed or cannot carry the seat), `handshake` (no snapshot in time: a blocked network), `stall` (snapshots stopped) or `server` (the server's `__fast:off`); null on the channel, while connecting, or when the game turned it off. It takes `__fast` once the socket's state is decoded (its `seqBase` is the player's), and an unusable one (not a uint32 sid, a 16-hex-digit key and a url, or a url it cannot open: in Node anything but `udp://host:port` with a port in 1..65535, an IPv6 host in brackets, and no credentials, path, query or fragment) is a fallback at once. Its wire seq is `seqBase` plus the input's count on the socket. The inputs it sent by WebSocket before the binding arrived were dropped by the server unstepped: its first datagrams carry them again, and whenever more new inputs are due than one window holds it sends one datagram per window's worth, oldest first, so none is stepped as a fill. While the channel is live the server drops its WebSocket inputs unstepped; its ack is its own seat record's `inAck`, except where a projectile prediction is settled (§17.9: its input acknowledged and its id not in the state), which reads the ack of the newest applied snapshot at or before the WebSocket's latest `tick` (the launch comes by WebSocket and can land after the snapshot of its tick; settled against the newest snapshot, a shot was dropped as refused before its launch arrived; held to the `settleAck` vectors); and it writes each applied snapshot into its state as a patch would (each record's fields through the schema, a field the record leaves at its default unwritten where the state never wrote one, with the state's `tick` and, in a predicting room, which patches only on keyframes, `keyframe` set to the snapshot's `tick`), then runs the state's change listeners, so prediction, reconcile and interpolation never see which channel delivered a state. A WebSocket patch that would write one of those fields for an id the channel holds is put back before any listener runs: a patch held behind a lost TCP segment never moves an entity or the tick back. The client keeps the WebSocket's latest value of each field it holds, and decodes each patch against those values (Colyseus sends a field only when it changes and reports a decoded field only when it differs from the one held). Motion comes by snapshot and the entity list by WebSocket, so an entity can be listed while the newest snapshot does not carry its record: it left your view and the WebSocket's removal is still on its way, or it came back into view by WebSocket first. Its fields then hold another keyframe's motion, so a predicting client keeps its body out of the keyframe it adopts, as one out of view (§17.11 *The client world*), until a snapshot carries it again. Every fast client with body prediction does (TypeScript, Unreal, Rust, Unity), held to the `motionKnown` vectors. Adopted as part of the keyframe, a body a keyframe behind failed the canary's exact comparison: on Mechanism Hall at `near-wifi`, where every radio episode releases snapshots and patches in separate bursts, prediction turned `diverged` for 7 of 28 fast seats a batch and for none on WebSocket. At a fallback, every held field takes the WebSocket's latest value in one patch, so no field keeps a snapshot's value the server has since moved on from (a seat that stopped while the snapshots were cut is drawn where it stopped); in a physics room an entity's motion instead keeps the channel's values until the server's full resend lands (*Downlink*: its WebSocket values date from when the channel went live), so nothing is drawn back where it was then. Each snapshot is a clock sample at its `sent`, with the round trip of the input its `inAck` newly acknowledges (timed from that input's send, which the client keeps for its newest `FAST_SEQ_AHEAD_MS` of inputs: the server consumes an input well after it received it), so a fast seat's clock reads what a WebSocket seat's does; the tick's nominal instant would read up to a step early, by however late the server ran that step. After a fallback the WebSocket ack counts that socket's WebSocket inputs (the dropped ones too), so the client's ack is its count at the fallback plus the WebSocket ack past the inputs it sent by WebSocket before it. A client may keep sending its input frames while live (Unreal and Rust, whose core's input handle exposes neither baseline below) or keep them off the socket (TypeScript and Unity, so a TCP stall holds no inputs); either way an input frame is a delta against the last frame the client *encoded* (the fields that changed since) and its lag-compensation stamp a delta against the last stamp it wrote, so a frame kept off the socket moves both baselines past what the server decoded. A client that keeps frames off must therefore, at the fallback, send its next frame whole (every field) and its next stamp as a delta against the last stamp the server got (the one before the binding); otherwise each input it sends after the fallback is stepped with the fields the server last decoded (a held axis read as 0) and judged seconds in the past. The server may end the channel with `__fast:off` `{}`; the client answers with its own `__fast:off { last }` and falls back as above (no server sends it yet). A client without `fast-return@1` that falls back before its first datagram loses the inputs it sent by WebSocket before the binding; one with it never does (*Return to the channel*).
1277
1333
 
1278
1334
  **Pacing** (§17.11 *Input pacing*, unchanged in P1). A recovered input usually arrives one datagram late, so recovery after a short loss is no burst; what stays bursty is a radio queue's delay (70–170 ms episodes on `real-mac-wifi`), which is the same on either transport. Fills and recovered inputs enter the seat's buffer like any other and the pacer is the same: `MAX_INPUTS_PER_TICK_PER_PLAYER` (4, `limits.ts`) and `PACE_MAX_DEPTH_S` (50 ms, `input-pacing.ts`) apply. Proposal, to be measured in P4 before it is adopted: a starve that ends with recovered inputs was loss, not jitter, so it should not hold the cushion for `PACE_KEEP_S` (the excess drains at the next `PACE_WINDOW_S`), and with `repeat@1` recovered inputs and fills pay owed repeats back first (absorbed, `feedWithPayback`), so a recovered burst is not stepped twice. P4 measures starved ticks, own popP99 and remote lurches on fast seats with the cap at 4 and at 2 and `PACE_MAX_DEPTH_S` at 0.05 and 0.1, against the WebSocket base.
1279
1335
 
@@ -1304,3 +1360,18 @@ Then `actions` held inputs, ascending seq, each `seq` uint32, `tick` uint16, `re
1304
1360
  - **`SetSchema`, quantized/angle fields, nested collections, `.unreliable()` and `.patchOnly()` in room state.** Native decoder support for them is unverified, or they break late joiners. The optional typed pose (§6.6) is therefore flat float32 and string fields, with yaw wrapped by receivers rather than stored as `t.angle()`.
1305
1361
  - **Typed pose on objects.** Only `PlayerState` carries the optional pose. `ObjectState.d` stays JSON until the player pose has won its A/B.
1306
1362
  - **Typed match state.** `match` stays JSON text until native decoders are shown to handle nested team rosters.
1363
+
1364
+ ### 17.13 Drain and rejoin
1365
+
1366
+ A deploy ends every room on the process it rolls, and a process can die. Neither is the game's doing, so a seat goes back into play by itself: the game keeps its session object, its listeners and its input object, and is told. `conformance/authority/authority-rejoin.json` pins which closes come back, which refusals are final and every attempt's wait, region and way in; the TypeScript, Unreal, Unity and Rust clients pass it exactly. Godot is a labelled skip: its authority session ends on the close, as before.
1367
+
1368
+ **The notice.** On SIGTERM a room stops taking seats and broadcasts `server:restart { drainMs }`. It plays on for `drainMs` (`AUTHORITY_DRAIN_MS`), then calls the module's `onDrain`, keeps what it returned, and closes every socket with **4001**. A client surfaces the notice as a typed event: `draining { closesInMs }` (`Draining` in Unreal, Unity and Rust), -1 when an older server sent `{}`. Playing on is the right answer. `server:restart` still reaches a game that listens for the message.
1369
+
1370
+ **Which closes come back.** 4001 (a drain, a host stall), 4003 (every reconnect attempt failed), 4010 and the transport closes 1001, 1005, 1006, 1011, 1012 and 1013. Never 1000 or 4000 (a leave), 4002 (a flood), 4401 (revoked), 4409 (a newer session), 4500 (halted) or 4501 (the module faulted): a client does not rejoin into the same failure. A session's `rejoin: false` makes a close that would come back final instead, and `disconnect` reports it as before.
1371
+
1372
+ **The attempts.** From the close: waits of 0, 250, 500, 1000, 2000 and then 4000 ms, each plus up to 250 ms by chance so a room's seats do not arrive at once. An attempt says `rejoining { attempt, code, waitMs }`. The seat stays with the region it was in for 60 s, then tries the next each 60 s, wrapping. A refusal of 400, 402, 403, 424 or 426 ends the rejoin, as does a budget of 180 s, long enough for a roll's next process to come up on a full node: the game then hears `disconnect` with the close it lost. Anything else goes on: 503, 429, an expired token (the next attempt asks for a new one), a matchmaker shutting down, a failed network. Success says `rejoined { code, url }`: a new seat (`id`) and a new room (`code`, the new invite). Telemetry starts over with the new seat. `leave()` stops all of it, and a seat an answer brings after a leave is given back.
1373
+
1374
+ **The way in.** A seat that lost its socket with no notice (anything but 4001) first asks for its room by id: the room may still live, and the seat goes back to it. Otherwise it joins with the option `successorOf: <the lost room's code>`, which is a room filter like `matchKey`, so a lost room's seats meet in one room and nobody else's. A public room's successor opens to the game's pool after 120 s (`SUCCESSOR_OPEN_MS`); a seat still looking after 90 s joins the pool. `successorClosed: true`, sent by a seat of a private room or one that joined by code, keeps the successor closed, and that seat keeps looking for it for the whole budget. Both options are refused with 400 when malformed, before a token is looked at; `successorOf` absent or empty means no successor, and the server writes `''` into the filter so a join without one never lands in a room that fills from one. They are optional keys: not a protocol bump.
1375
+
1376
+ **The state.** A game chooses. A module with `onDrain(ctx)` returns what to hand on, at most 256 KiB of JSON (`AuthorityLimits.MAX_HANDOFF_CHARS`), called once under `init`'s time limit; the room that follows reads it as `ctx.handoff` in `init`, once. Its seats come back with new session ids, so a module keys what it keeps by uid. Every other module starts clean, which is the default: a new `init`, and `onJoin` for each seat as it returns. The hand-off waits in the node's own Redis for 300 s, keyed by the verified game and the lost room, so a room code can only ever name a hand-off of its own game, and a successor in another region or on another node starts clean. A module that throws, returns too much or returns what JSON cannot hold costs the hand-off, never the close and never a strike; so does a store that fails or takes over a second.
1377
+