The development relay keeps gameplay protocol 4 and persistent rooms keep protocol 1. Both now support the generated binary encoding described below, with JSON available for diagnostics. The historical sections describe the original relay contract; the encoding increment does not close the full recovery roadmap.
The browser obtains a session from POST /api/session, then connects to /room with an allowed Origin. Its hello includes protocol, world release, content fingerprint, session token and player pose. The relay supplies the admitted name, identity, color and motion stream. A pose cannot replace those fields. Protocol 3 adds optional validated locomotion: {speed, grounded} to poses/peers. Motor-relative speed is 0–8 m/s and grounding is boolean; clients still cannot supply relay motion stamps. Protocol 4 adds owned equipment intents and shared selections. Older protocol 1–3 clients receive code 1008 and must reload a matching client build. A room admits eight active travelers.
#Relay-to-browser envelopes
packages/net/packets.ts validates the complete decoded envelope before RoomClient changes state. Each variant has exact fields; optional nested fields are validated per variant. Snapshots and nested values are copied into browser ownership. Unknown variants, extra fields, malformed sequences and invalid world references reject the connection with WebSocket code 4003.
| Type | Required fields besides type |
Meaning |
|---|---|---|
baseline |
self, host, epoch, peers, shared |
Complete accepted shared state and peer view; optional interest scopes peers. Self must appear; a nonempty host may be elsewhere when the acknowledged room total permits it. |
routes |
epoch, time |
Relay-owned elapsed seconds for authored-route worlds; finite, nonnegative and at most 1e12. |
presence |
peers |
Complete peer view with relay-owned motion stamps; optional interest scopes it to the recipient’s place. |
authority |
host, epoch, shared |
Election or resynchronization; host may be empty while no authority exists. |
intent |
epoch, intent |
An admitted actor's interaction request for the elected browser; negotiated optional peers supplies actors in the action’s place. |
commit |
shared, intent, actor, accepted |
Accepted snapshot and interaction receipt; optional string reason is limited to 240 characters. |
chat |
message |
Ephemeral {id,name,text}; name is 1–24 and text 1–280 characters. |
error |
message |
Nonempty message limited to 2,048 characters. |
Epochs, shared revisions and motion sequences are canonical decimal uint64 strings. Peer motion also includes a stream identifier and monotonic sample time; the peer reader bounds the list, rejects duplicates and validates the complete pose. Names and chat are text, never executable markup.
shared requires revision, doors, flags and items, and optionally admits props, npcs and equipped. NPC keys name authored patrol interactions; each value has bounded phase, until and an owner traveler ID (or empty after release). The map allows at most eight entries. Prop keys name authored sliding interactions; values are integer steps within their authored path, with at most 32 entries. Door IDs must refer to authored door interactions and values are boolean. Flags must be unique authored interaction flags. Item IDs refer to pickups and values are traveler identifiers. Each collection is bounded at 64 entries. Route time is sent separately from interaction revisions; it is not committed by the elected browser. The relay persists its origin before admission and sends the clock after baseline and at 10 Hz. Clients require the admitted epoch, reject regressive samples within a connection, and extrapolate for at most one second. See garden ferry contract.
An intent requires id, actor, action and pose, and optionally admits propStep, an integer from 0 to 16, time (room seconds), and conversation (begin, renew, or end). The relay stamps patrol intents with its own clock; client-supplied time is ignored. An active conversation belongs to one traveler and expires unless renewed. A sliding-prop push requires this precondition to match the current saved step. Retries preserve it. The relay's request identifier consists of a 16-digit sequence, a hyphen and a 36-character lowercase hexadecimal/hyphen identifier. Action strings are limited to 100 characters; unknown or empty actions remain valid requests that the authority reducer rejects as unavailable. They must not disconnect the host merely because the action does not exist.
Ordinary commit receipts carry that request ID and an actor identifier. The coordinator also publishes successful item returns using actor coordinator and intent system.return.<revision> matching the shared revision. Those receipts have no denial reason.
#Optional place interest
A protocol-4 client may send placeInterest: 1 in hello. A supporting relay acknowledges it with interest: {place, sequence, total} on every baseline and presence packet for that connection. peers then includes only travelers in the recipient’s current place, including self. total is the whole-room count (1–8), so the elected host may be absent from these rows. Without acknowledgement the client keeps legacy whole-room behavior; clients omitting the capability receive legacy envelopes. Negotiated mode cannot change within a connection.
The sequence is a canonical uint64, increasing for each recipient view. These are full replacements: gaps are allowed, duplicates and regressions are rejected. A new socket starts a new sequence domain; resync and gateway travel on the same socket do not. The relay publishes changed membership immediately after admitting a place-changing pose, in addition to its regular presence broadcasts. Gateways publish that pose after local collision-ready travel.
A scoped elected browser receives a bounded peers context on each forwarded intent, containing current peers in the action’s place. This lets a host elsewhere check doorway/prop occupancy. Context is refreshed when pending requests are forwarded again. Legacy authorities retain the original intent shape and whole-room peer view. Shared commits, chat, elections and route clocks remain room-wide. See place interest for verification and limits.
#Optional portal destination interest
A client supporting authored windows sends portalInterest: 1 alongside placeInterest: 1. A supporting relay adds destinations: [] | [placeId] to every interest snapshot, including when the current place has no window. The optional field is the acknowledgement; its presence cannot change within a connection. Unknown capability versions or portal capability without place capability reject admission. Existing place-only and whole-room clients retain their original wire shapes. The room actor cap remains eight.
The server derives the destination from the current place's authored window and filters peers before serialization. It does not accept a destination list from the traveler. Only one direct destination is included; a window in that destination does not recurse. The reader owns the metadata, checks the declared edge against the accepted world, rejects extra destinations/foreign peer rows, and verifies that self belongs to the snapshot's primary place. Total remains the room-wide player count. Interaction authority context remains scoped to the interaction place, independently of portal interest.
During a development revision, clients can temporarily hold different accepted world graphs. Portal-capable connections receive {type: "heartbeat"} instead of presence/resync snapshots while the revision barrier is locked. This keeps the transport alive without sending a destination edge from the wrong world revision. Resume/abort publishes the matching baseline after the barrier is released. The heartbeat is a strictly fieldless server packet and does not change room state. Existing clients receive their original presence path.
Persistent-room hello version 1 accepts the same optional portalInterest: 1. The session response advertises portalInterest: 1, and new clients request it only when advertised. An older retained server gets its original strict hello even when serving format 17. Persistent membership comes from committed server travel, and a pose cannot choose another place. Both transports use the same bounded destination selector. Its defensive requirement check uses shared doors/flags and the requesting actor's item ownership; current format-17 anchored windows are unconditional, and this does not introduce an authored condition UI or schema.
This is transport support. Player destination presentation, bounded asset residency and graphics recovery still need integration before people are visible through windows. The cooperative relay still trusts admitted client poses and is not an anti-cheat boundary.
#State and connection fencing
Before admission, the client limits incoming messages to 128 KiB of string characters. A rejected envelope cannot partially replace shared state. Messages already queued behind a rejection are ignored once that socket starts closing, and callbacks from disposed or replaced sockets cannot change the active room.
A new baseline must retain an already admitted self identity and cannot decrease the accepted epoch or shared revision. Older authority epochs are ignored; a current/new authority message cannot decrease the accepted shared revision. Existing commit handling retains the highest accepted revision while acknowledging the matching interaction. These checks preserve accepted local room state during reconnection; they do not create a trusted simulation authority.
The relay elects a browser, fences commits with its persisted epoch and current revision, and writes accepted state before acknowledging it. A silent browser loses its lease. The existing real-socket tests cover duplicate intents, stale commits, handoff, restart and item exclusivity. Browser tests exercise doors, chat, pickups, visible peers and shared graphics recovery.
#Remaining full-plan work
The original open-items list included generated transport schemas and binary comparison traces (now delivered below), immutable join/catchup snapshots, explicit acknowledgment tiers, shaped-network coverage, general replicated moving-platform state and trusted server simulation remain open. Schema admission checks validate structure and authored references; they do not prove an elected browser's game rules are honest. See milestones 5, 6 and 10 in IMPLEMENTATION_ROADMAP.md.
Equipment intents additionally carry equipment: boolean, with the pickup ID in action. Accepted equipped item IDs must have an owner and an authored equipment definition, with unique slots per owner. Carry/stow requests use the same fencing, receipts and durable commit path; see equipment contract.
#Generated binary room transport
Current browser builds request darkwind.binary.1.<schema-sha256> over WebSocket.
The cooperative relay and trusted persistent host negotiate that exact contract;
the regional router forwards it. A JSON client and a binary client can share the
same room. To select the diagnostic encoding, add &wire=json to a play URL
that already has a query, or ?wire=json otherwise. Existing clients that offer
no subprotocol remain JSON clients. A different binary schema does not silently
negotiate compatibility: use the matching retained engine or explicitly choose
JSON with the existing gameplay/content compatibility checks.
The source contract is packages/schema/wire.ts. Explicit numeric message IDs are
append-only. npm run wire:generate produces packages/net/wire.generated.ts from
that catalog, the shared InputFrameContract and built-in component descriptors.
It contains the schema fingerprint, message field slots, input encoding program,
and component/field name dictionary. npm run check checks that generation is
current. Input checkpoint validation consumes the same component metadata, so its
bounds and binary reader do not maintain separate numeric rules.
A binary frame has a 44-byte header: magic, encoding version, flags, message ID, header length, full schema digest and payload length. The payload begins with a field-presence mask. Authoritative inputs use schema-generated fixed fields, including unsigned 64-bit sequences. Other payloads use bounded tagged values and dictionary keys; UUIDs, hashes and canonical unsigned-integer strings have compact representations. Dynamic maps remain supported for game-specific state. This is an encoding layer: the existing content-aware packet readers, authority/lease checks, visibility filtering, sequence checks and gameplay validation still run. It does not replace them with a permissive binary decoder.
Both encodings enforce the same message catalog and structural limits. Client frames are at most 64 KiB at the socket; server frames are at most 128 KiB. The codec limits nesting to 32, values to 16,384, collections to 4,096 and individual UTF-8 strings to 64 KiB. Invalid lengths/tags, duplicate or prototype keys, nonfinite numbers, unknown message IDs/fields, trailing bytes, truncation and schema mismatch reject before application state changes. The writer grows within its byte budget rather than allocating the maximum buffer for every input. Large content continues through the existing chunked content path; this does not introduce an unlimited room-snapshot parse.
Authoritative numbers are lossless float64 values. The library's opt-in
quantizePresentation option applies only to presence peers: positions use
1 mm steps and yaw uses 0.0001 radian steps. It cannot quantize inputs, motion
acknowledgments or durable state. It is off by default. WebSocket compression
also stays off. Quantization, compression and network latency need representative
production/device evidence before changing those defaults.
#Measured comparison and focused acceptance
node --import tsx --test tests/wire.test.ts
npm run test:control -- tests/wire-room.test.ts
npm run bench:wireThe room check records a bounded real traffic trace under test-results/; the
benchmark re-encodes those same packets. On October 26, the lightweight two-player
trusted-room trace contained 78 messages (baseline, presence, motion, input,
heartbeat and chat):
| Encoding | Bytes | Encode µs/message | Decode µs/message |
|---|---|---|---|
| JSON, raw stringify/parse | 24,229 | 0.59 | 0.84 |
| JSON with shared catalog validation | 24,229 | 8.57 | 8.12 |
| Binary, exact | 16,307 | 6.34 | 3.93 |
Binary reduced raw bytes by 32.7% on this trace. It costs more CPU than raw JSON stringify/parse, while beating the current JSON path with equivalent catalog validation. The diagnostic JSON implementation currently validates by exercising the binary writer, so that comparison is not a claim that a separately optimized JSON validator must be slower. Empty heartbeats grew from 20 to 48 bytes because of the versioned header. Input messages shrank from 2,951 to 2,000 bytes total.
Per-message raw-deflate comparison produced 14,980 bytes for JSON and 11,173 for binary, with about 11.8/11.2 µs compression cost per message. Optional presence quantization produced 15,779 uncompressed bytes. These measurements do not include WebSocket/TLS overhead or decompression cost. Ten heartbeat observations per encoding were roughly 9–11 ms for both, dominated by the test's 10 ms observation poll; they provide no evidence of a latency improvement or WAN performance.
Three focused codec checks pass, including the committed golden corpus, every catalog kind's envelope round trip, truncation/corruption and quantization bounds. The trusted room check verifies mixed encodings, acknowledged inputs and isolated rejection of a corrupt connection. The cooperative relay check verifies mixed encodings and shared chat. The real two-browser Shared Lantern journey passes with binary transport, including shared interactions, reward and reload. Type, import, generated-catalog and client build checks pass. A first fixture used an old world format without trusted-authority support; it was corrected. A later production-art fixture received admission pressure, so the transport check uses the lightweight reference; no production admission limit was loosened.
Review later / unfinished: qualify routed binary handshakes end to end, all cargo/revision/floating payloads, reconnect/handoff under shaped networks, maximum baselines, sustained allocation/CPU and physical devices. The all-kind codec check proves encoding, not every kind's gameplay semantics. Extend schema-derived typed payload coverage beyond inputs where the final component/replication audit requires it; dynamic payloads currently retain their established semantic validators. Compare compression/decompression and packet scheduling on representative traces, and measure latency without polling. General acknowledged-envelope generations, traffic-lane separation and full roadmap-6 recovery acceptance still require the final requirement audit. This increment does not close those gates.