Repository navigation
Releases
v3.1.0
- A handler that keeps throwing can no longer stall a player's events or end the session. A rejected event is now
offered at mostevents.maxRejectionstimes (default 10, about a second), then dropped and acknowledged, so the
events behind it continue in order. If the queue behind it fills first, it gets one last offer and is then dropped,
instead of the session being closed withEvent sequence gap.stats.eventsDroppedcounts these andonError
reports each one. An ack can now also mean the receiver gave up on the event. This changes the previous "offered
until accepted" behavior: a handler that throws to wait for something slower than a second should set
maxRejections: Infinity, which keeps the old behavior, including session loss on a full queue. - Extra copies of a rejected event, such as its datagram mirror, wait for the 100 ms retry delay and no longer count
as attempts. Code usingReliableEventsdirectly must calldrainto retry a rejected event. - The rejected-event change above needs no wire change and is local to the receiving end.
- A resumed
welcomenow carries the server's event counters (rreceived,aacknowledged). These fields are
additive and wire generation 5 is unchanged: older clients ignore them, and a client talking to an older server
resumes as before, so either side can upgrade first. - Fix resuming from a checkpoint older than the last event. The old page's later events made the server's ack look
invalid, and the session was lost withprotocol. Now the client lines up with the server before it sends anything.
Events queued since the restore move past what the server already has, so they are no longer dropped as duplicates.
Server events the checkpoint never saw and the server has since forgotten are skipped and reported through
onError, so the events behind them still arrive.sendEvent's returned sequence number can change for such a
queued event. - A throw or rejected promise from
authenticateis now a temporary failure: the socket closes with 1013, the client
retries, and the session and its seat are kept. Returningnullstill ends the session. Applies to the Worker
adapter too. - Fix a player kicked while a resume awaited
iceServersstayingonlineon a removed session. The client now gets a
session-unavailable close (4001). - Fix a new session being expired during an async
iceServersonce the server had been up longer than
resumeGraceMs, which left that player online but never pumped. A new session abandoned during that wait now frees
its slot at once.
v3.0.1
- Fix sustained underuse of the event byte share when reliable events exceed the burst. Repay bounded one-packet
debt before the next oversized event instead of repeatedly waiting for a full bucket and clipping pump overshoot.
Preserve state scheduling, ordered replay, protected credit, rejected-write refunds and the configured byte rate. - Add minute-long real-browser state-age and reliable-throughput gates against the unchanged published 2.3 baseline.
With an explicit throughput-oriented state share of 0.075, five alternating pairs per browser/path deliver lower
p95 draw-submission state age and at least as many reliable events in every pair. The default remains 0.5; this
balanced setting does not retain the earlier default-share 66% age benefit. See validation/RELIABLE_PARITY.md. - Wire generation 5 and the application APIs are unchanged. This patch is compatible with 3.0.0 peers. Actual-game
rendering measurements are separate from the transport comparison; no general game-FPS increase is promised.
Sustained latency with reliable throughput
80 valid trials /40 pairs. Five alternating minute-long overload pairs per browser/path, plus normal controls. Unchanged2.3 baseline; candidate state share0.075, default remains0.5.
| Browser/path | Workload | Median p95 age before→after | Paired age change | Median events before→after | Combined parity benefit |
|---|---|---|---|---|---|
| chromium/ws | normal | 59.4→60.0ms | 0.9% | 0→0 | no |
| chromium/ws | mixed-budget | 171.8→140.9ms | -18.1% | 323→331 | yes |
| chromium/rtc | normal | 59.5→59.9ms | 0.7% | 0→0 | no |
| chromium/rtc | mixed-budget | 171.1→141.2ms | -17.4% | 323→331 | yes |
| firefox/ws | normal | 60.3→60.0ms | -0.5% | 0→0 | no |
| firefox/ws | mixed-budget | 172.7→140.4ms | -18.9% | 323→331 | yes |
| firefox/rtc | normal | 59.7→59.4ms | 0.4% | 0→0 | no |
| firefox/rtc | mixed-budget | 170.1→140.8ms | -17.1% | 323→331 | yes |
Before/after medians and paired median ratios are separate summaries. Age is generation to Canvas draw submission, not physical presentation. Every overload pair must improve age by≥10% beyond both clock estimates and receive at least as many ordered events.
- Only the mixed-budget setting qualifies; this retains less freshness than the original default-share comparison.
- Same configured primary application byte rate and burst; bounded oversized-packet debt, exempt control and mirrored datagram bytes are reported separately.
- No universal network/device/latency guarantee. Actual-game FPS is measured separately.
- All earlier failed pilots and a rejected state-debt prototype remain separate diagnostics; they are not pooled into these comparisons.
Raw browser evidence · Completed comparison
Actual-game rendering
CS2, Nocturne Keep and kimu completed60 primary rendering trials/30 pairs and same-session RTC reconnects, with unchanged game/rendering code and the exact changed scheduler method isolated on the client. No consistent FPS gain was demonstrated. Nocturne and kimu are near this desktop’s240Hz callback limit at native CPU speed. CS2’s large frame-rate shifts also occur with byte-identical scheduler roles, so its live A/B comparison does not establish a causal gain or regression. All negative pairs, the identical-code control and the scoped CPU-cost diagnostic remain in the evidence. These measurements used the same3.0.0 game authority for both client roles; they do not certify a changed authority’s latency.
Game results and limits · Raw game metrics · Official package file verification
The matching 3.0.1 clients and servers are now deployed for CS2, Nocturne Keep and kimu. Live movement/gameplay and same-player reconnects pass on native RTC and forced WebSocket. Their hosted game gates pass, including ten-minute reconnect/replay soaks. The attached deployed-game-acceptance-3.0.1.json records the tested sources, CI runs and functional acceptance; the separate rendering measurements do not demonstrate a repeatable FPS gain.
v3.0.0
- Wire generation 5 adds optional authenticated WebTransport datagrams. Bind each physical HTTP/3 path to the live
WebSocket session using a short-lived, one-use ticket and epoch; prove both directions before selecting it. Keep
independently proven RTC and WS fallback, path-size limits and hysteresis. Reliable session/events remain on WS.
Respect changing datagram limits and provider-owned stream teardown; standby proof clears retry debt too.
This protocol change requires paired client/server deployment; 2.x clients cannot connect to 3.0 hosts. - Add optional bounded metadata tracing for state enqueue, send, receipt, accepted application, playback and explicit
render submission. Report clock uncertainty and separate queue time from playback age; never retain payloads/secrets. - Add bounded cancellable HTTP bulk resources with browser admission/memory limits, optional SHA-256 integrity and a
Node handler with authorization, origin checks, ETags and backpressure. Bulk bytes stay outside ordered game events.
Stalled downloads time out, older AbortSignals work, malformed targets return400 and source buffers are snapshotted. - Protect state and reliable-event bandwidth under mixed load while lending surplus positive credit. Refund failed
writes and allow useful smaller states past unsendable large candidates. Preserve ordered event delivery. - Add regressions, deterministic comparison with published 2.3.0 and hosted real QUIC/browser and alternating-process
localhost benchmark gates. Candidate/release evidence is recorded in validation/STATUS.md; no universal speedup claim. - Fix mutable Node Buffer views at state/frame/replica ownership boundaries: later caller mutations cannot change
an already queued or decoded snapshot. Require patched ws8.22+ and patch the pinned local Worker tool's dependencies.
Verified rollout
Matching clients and servers are deployed in CS2, Nocturne Keep, kimu.nyc, Superfighters, Emoji Mahjong, Gallax, Webstrike, Sipoga, Wollosseum, TDBR and Oak0. Public gameplay and reconnect checks pass for all eleven, including private Mahjong hands and seats in phone-sized WebKit.
All four one-hour SDK lifecycle soaks passed. The additional hour-long CS2, Nocturne and kimu game checks passed: CS2 recovered 57 times on each path; Nocturne handled 60 drops and 60 pauses; kimu accounted for all 709,168 online frames with no missing group members. Scheduling stalls and nonzero playback errors remain visible in the game evidence; kimu's raster-suppressed update cadence is not render FPS.
The website passes all 24 pages at desktop and phone widths in light and dark modes, plus all 37 interactive checks. Play the live starter, or download the Node starter: both use 3.0.0 and include an optional bounded timing recorder. A clean public-download install passes native RTC and forced WebSocket movement, reconnect, one queued ping, checkpoint reload and timing-toggle checks. Website and starter verification.
Rendered browser comparison
Protocol and reproduction · Raw evidence · Completed workflow
Five alternating paired trials per workload compare identical scene code on 2.3.0 and 3.0.0: 100 valid trials, 50 pairs. Pixel captures are outside aligned measurement windows. Native RTC requires actual measured RTC application states. The original failed capture attempt is retained separately in the evidence.
| Browser / path | Workload | v2.3 median p95 age | v3 median p95 age | Median paired age change | Median scene cadence change | Repeated age benefit |
|---|---|---|---|---|---|---|
| chromium / ws | normal | 58.7 ms | 59.3 ms | 0.6% | -0.0% | no |
| chromium / ws | mixed-budget | 168.5 ms | 56.8 ms | -66.3% | 0.0% | yes |
| chromium / ws | render-load | 61.3 ms | 61.7 ms | -1.1% | -0.4% | no |
| chromium / rtc | normal | 59.8 ms | 60.0 ms | -0.0% | -0.1% | no |
| chromium / rtc | mixed-budget | 169.0 ms | 58.1 ms | -65.6% | -0.0% | yes |
| firefox / ws | normal | 60.4 ms | 59.5 ms | -1.1% | 0.0% | no |
| firefox / ws | mixed-budget | 170.0 ms | 57.2 ms | -66.1% | 0.0% | yes |
| firefox / ws | render-load | 64.2 ms | 63.7 ms | -0.8% | 0.0% | no |
| firefox / rtc | normal | 59.4 ms | 59.6 ms | 0.4% | 0.0% | no |
| firefox / rtc | mixed-budget | 167.9 ms | 57.6 ms | -65.9% | 0.0% | yes |
Before/after values are separate medians; the paired-change column summarizes within-pair ratios and can differ from the ratio of those medians.
Age means state generation to JavaScript Canvas draw submission, including application scheduling; it is not packet transit or physical presentation. A repeated benefit requires every pair to improve p95 age by at least 10% beyond the reported clock estimates. No workload demonstrated a consistent scene-cadence gain of at least 5% in every pair.
The mixed-budget case deliberately overloads a 4,000 B/s application budget with reliable events. Median reliable events received during each 12-second window changed from 65 to 60 in all four browser/path combinations. Bounded outboxes rejected hundreds of excess attempts in both versions. Every received/rejected event count and event-age percentile is retained. The 12,000-rectangle workload preserves renderer stalls and is compared over forced WebSocket. These findings support workload-specific improvements, not universally lower latency or higher game FPS.
v2.3.0
-
The server now measures each client's round trip itself: every
probeMs(2000 by default, 0 turns it off) it sends a small probe that clients echo at once, and keeps a smoothedsession.rttMsand the connection'ssession.minRttMs. This is the independent observationselectRewindTimeasks for: expect a shooter's view half the round trip, its playback delay (which already includes the other half), a frame and its input-queue wait behindnow(docs/ADOPTING.md has the model and a calibration). Both hosts (Node and Worker) probe; clients before this release never answer and leaverttMsnull, so keep a fallback. Measurements restart on each connection. Wire protocol 4 is unchanged: a 2.2 client ignores probes, and a 2.2 server never sends them. -
Tests build and pass on Windows: a worker-bundle path no longer goes through
URL.pathname, and the 128 Hz pump test drives its game loop by elapsed time, stating that a Windows host's coarse timers cannot run a 128 Hz pump (flush()per tick still carries every state). -
Restore Safari 15.0–15.3 compatibility for default clones across sub-tick capture/replay, prediction, interpolation, input inboxes and reliable-event checkpoints. Use native
structuredClonewhere available; otherwise clone common structured input data without JSON serialization loss, with explicit failure for unsupported objects. Add missing-native browser and regression coverage; protocol and dependencies are unchanged. -
Add optional
SubtickInputBufferandreplaySubticknetcode helpers: capture monotonic client input edges into fixed-tick fractions and replay segments deterministically without changing simulation tick rate or the wire protocol. Queue bounds, skipped windows, nonmonotonic times and invalid fractions fail explicitly. -
Add
selectRewindTimefor Source-style server-derived rewind-time validation. It falls back to an independently estimated server time for implausible client requests only while that estimate is within history. Otherwise it returnstimeMs: nulland requires explicit game policy; game authority and hit verification remain application responsibilities. -
Add dedicated edge/boundary/overflow/forged-time regressions and documentation. Freeze replay scheduling and clone input values before invoking game callbacks; reject reentrant input cloning and arithmetic overflow in derived history bounds. Existing transports, games and API defaults are unchanged.
Release validation is recorded in validation/STATUS.md.
v2.2.0
-
The Worker host explicitly selects ArrayBuffer binary delivery and completes close handshakes on older runtimes. Real workerd regressions failed under the modern Blob default and the older manual-close behavior before this fix; both compatibility dates are release gates.
-
A closing browser socket now holds playback and reports recovery promptly. Heartbeat/handshake timeouts retire and generation-fence the old socket before retrying, so an offline close handshake cannot keep the client apparently online or suppress a resume. Late callbacks from the retired socket cannot end the new connection. A real-socket fault regression failed before this fix.
-
Added named EventClient/createEventServer helpers for migrating existing games. Configured names are independent latest-wins JSON streams with recovery gates; other names are acknowledged events. Handlers, player identity and room membership survive a physical reconnect. Pauses and terminal session expiry have distinct callbacks.
-
Added twinwire/worker, a standard-WebSocket SocketSessionServer for a long-lived Worker or Durable Object instance. It shares the core wire protocol, GameSession and delivery engine with the Node host. It has exact origin policy, bounded admission/rate limits, authentication revalidation, token/room/subject binding, takeover fencing, heartbeat/handshake timeouts and session expiry. It provides no native RTC, durable transport storage or hibernation restoration.
-
New channel/Worker integration tests cover recovery, offline event replay, latest-state readiness, private room broadcast, explicit expiry, takeover and auth revalidation. The auto-connect helper cannot resurrect a client closed during its creation task.
-
Added complete Node and Worker starter projects sharing an interactive two-tab client. They demonstrate authoritative movement, ordered pings, temporary interruption and checkpoint reload. Worker arenas use invite URLs and WebSocket; the Node starter can use native RTC.
Release validation is recorded in validation/STATUS.md. Game migrations and the website rollout have their own deployment receipts.
v2.1.0
-
Snapshot generation age is checked continuously, including while the transport stays online. Old payloads in a
receive backlog cannot keep advancing remote replay after its delay/prediction budget is exhausted. This holds
the declared playback time and retains histories; it does not invent missing movement or hide present-position lag. -
GameClient.receiveState/StateReceiver: shared reconnect recovery for authoritative snapshot streams. It
resets or retains application histories by policy, holds playback through offline and online-without-fresh-data
phases, and accepts recovery only after a usable current-connection snapshot on the selected stream. New sessions
always reset. Idle snapshot headers can release recovery; rejected deltas, queued old state and unrelated streams
cannot. Raw callbacks and wire protocol 4 are unchanged. The party example uses the shared receiver. -
AdaptivePlayback.updateDelay(dtMs)separates delay adaptation from cursor movement. The receiver learns valid
recovery-header lateness while holding its cursor, so a slower new route can recover without advancing old state. -
A proven RTC recovery now clears retry backoff, so an intermittent network outage does not inherit debt from an
earlier recovered outage. Duplicatertc-resetcontrols for an already-stopped attempt no longer increase that
debt. This changes no wire bytes or application API. The native gate repeats three recoveries and injects duplicate
resets; OS-level one-way UDP checks also retain their original failing observation and the candidate rerun. -
Queued traffic on a socket retired by session takeover is ignored, including a repeated hello. It cannot reclaim
the player or produce misleading new protocol errors while its close is still in flight. Unauthenticated binary
traffic on a new connection remains rejected.
v2.0.0
For shooters that send a snapshot every tick, and a smaller wire. Protocol version 4: server and clients upgrade
together (a 1.x peer can't talk to a 2.x one).
-
Frame header: 22 bytes to 12 (14 at most): one byte for version and kind, a 16-bit connection epoch, the stream as
a varint, the 32-bit sequence number, and the time as quarter milliseconds from the connection's time base (sent in
the welcome). A 64 Hz shooter's 35-byte inputs were more than a third header. The server reconnects a connection
after four days so its frame times stay in range; the session resumes.Endpoint.sendStaterefuses a time its frame
can't carry (more thanMAX_TIME_SPAN_MSfrom the time base) when called, instead of throwing from a later flush. -
Removed what was only there for compatibility:
MAX_DATAGRAM_BYTES(useMTU_STATE_BYTES) and the
new ReliableEvents(maxEvents, maxBytes)form (pass an options object). -
GameServer({ pumpHz }): how often queued states and events go out (default 60, 10 to 250). At 64 or 128 Hz
the default pump coalesced states between pumps, now and then dropping a tick from a stream interpolated two ticks
behind; set the tick rate here, orsession.flush()after each snapshot. -
PROFILES.fps64/fps128: a snapshot every tick, the bandwidth for it (96 / 192 KiB/s) and two ticks of render
delay. -
AdaptivePlayback.track(timeline, sample, arrival)inserts a sample and counts its lateness, but not a timeline's
first sample or a duplicate. Observing everything a join hands you (an idle player's state from a second ago) set
the delay to hundreds of ms for every entity for about 9 s (kimu.nyc: 280 ms after a join, 40 ms with this). -
ADOPTING: a shooter section (send rate, bandwidth, inputs, datagram size, late states, lag compensation). API: a
sample'sseqis the sender's sequence number, never derived from a time that crossed the wire.
v1.1.2
- Clock across reconnects: a closed socket reset the client's clock, so through every outage
serverTime()
silently became the page's own time, and a playback that never goes backwards could lock onto that timeline and
freeze after reconnecting to a restarted server (kimu.nyc measured remote players stuck and reversing for 10 s).
The estimate is now kept through a drop (a resumed session talks to the same server, so the same clock) and starts
over only with a new session; every welcome still brings a fresh burst of pings. AdaptivePlaybackstarts over when the clock jumps back by more than a second (a new server) instead of holding
until the new clock passes the old cursor; a growing delay still never rewinds it.- Docs: hold playback until
clock.synced, and reset it inonSessionwhenresumedis false.
v1.1.1
- Page unload:
bindPageLifecycle()paused the session when a page closed or reloaded, so in a game without
checkpoints the old player stood frozen for the whole resume grace while the reloaded page joined as someone new.
Now a real unload (not the back/forward cache) leaves at once, unless the client checkpoints (unload: 'auto', the
default);unload: 'pause'or'leave'says so explicitly. Found by kimu.nyc's playtest.
v1.1.0
Accuracy improvements measured by porting a 50-player shared world (kimu.nyc) onto Twinwire, plus everything in 1.0.1.
- Clock sync: a burst of pings after each connect (
syncPings8,syncPingMs250, thenpingMs1000) instead of
one a second after the first reply, andNetworkClockjumps to an estimate more thanjumpMs(50) away instead of
slewing for seconds. With 50 clients on an emulated 5G link, the spread of their server-time estimates had stayed at
20 to 27 ms; every sample's timestamp carries that error, so it widened everyone's interpolation delay. AdaptivePlayback: lateness is a time-decayed histogram (2 ms buckets,halfLifeMs2000) instead of the last
128 observations, so its memory is a span of time: fed one observation per snapshot at 60 Hz it had forgotten a
congested cell's stalls within two seconds and under-sized the delay.latenessMsexposes the covered percentile.- Late states (
endpoint.lateStates: 'deliver'): a state overtaken by a newer one on its stream is delivered once
withlate: true(within the last 32, de-duplicated; acks still follow the newest), so a 60 Hz snapshot stream with
jitter fills its interpolation buffer instead of losing every reordered snapshot. Default unchanged ('drop'). GameSession.flush()sends now instead of at the next 60 Hz pump.sendEvent,broadcastandRoom.sendaccept
typed interfaces (JsonInput), still checked when serialized.- Impairment:
stallMsstalls the WebSocket the way a retransmitted TCP segment does: with probabilityloss, a
send and everything queued behind it waitsstallMslonger, in order (stats.impairedStalls). Without it,loss
still drops datagrams only. This makes a lossy cellular network comparable across the two wires in load tests.