Problem: server-authoritative play has no prediction, interpolation, delta wire, resume, or RTT. Verified on main: packages/shell/src/drivers/FrameDriver.tsx skips movement, onTick, and behaviors when serverAuthoritative; packages/ws/src/hostRouter.ts:364 pushes the full getServerView; packages/core/src/runtime/worldReplication.ts WorldDiff has no transport; packages/core/src/runtime/hostedWorldSession.ts:21-22 HostedWorldStore.load/save are synchronous; no ping message in packages/ws/src/protocol.ts; packages/shell/src/inputSink.ts keeps one pending frame.
Done in #1692: ctx.sim.tick(), InputFrame.tick, createInputRecorder, createSimSnapshotRegistry.
Tasks, one PR each, in order:
-
Interpolation buffer (core). New packages/core/src/runtime/snapshotBuffer.ts: createSnapshotBuffer<T>({ delayMs, capacity }) with push(atMs, value), sampleAt(nowMs) -> { before, after, alpha } | null (renders delayMs behind the newest), snapshot, restore, retune. Tests: alpha between two samples, clamps at ends, drops old entries. bun run gen. CHANGELOG Added.
-
Remote players interpolate. In packages/shell/src/world/WorldScene.tsx RemotePlayers, keep a createSnapshotBuffer per user fed by presence rows and render the sampled pose in useFrame instead of the raw row. Add presenceInterpolationMs?: number (default 100) to the multiplayer presentation config. Clip: two-client local test with examples/express-host showing the remote capsule moving smoothly at 10 Hz presence.
-
Host input queue. In packages/core/src/runtime/hostedGameRunner.ts replace the single inputs map with createInputRecorder per user; tick runs ctx.sim.advance and for each step feeds recorder.frameAt(tick) into ctx.game.players.setInput; keep heldInput returning the newest. Test in hostedGameRunner.test.ts: two frames with ticks 1 and 3 apply on the right ticks.
-
Ping and RTT. Add { v: 1; t: "ping"; id: number; at: number } / { t: "pong"; id; at; serverAt } to packages/ws/src/protocol.ts; createWsBackend sends one per second and keeps rtt: { sampleMs; smoothedMs } on the backend object; hostRouter answers. Test with the loopback transport.
-
Delta wire. Add { v: 1; t: "world"; serverId; frame: { kind: "baseline"; revision; snapshot } | { kind: "diff"; diff: WorldDiff } } to the protocol; in hostRouter.ts push session.pull(sinceRevision) per subscriber (it already returns baseline or diff) instead of getServerView; in packages/shell/src/worldSync.ts apply diffs through worldMirror and request a baseline on needsResync. Add quantize?: { position: number; angle: number } to ReplicationPolicy and round entity positions before diffing. Test: a moving entity produces a diff smaller than the baseline; a client that missed a revision resyncs.
-
Prediction and reconciliation. New packages/core/src/runtime/prediction.ts: createPredictionBuffer<TState>({ step: (state, input, dt) => TState; maxTicks }) with record(tick, input, state), reconcile({ tick, state }) -> { replayed: number; error: number } (re-simulate recorded inputs after the authoritative tick), snapshot, restore. Then in FrameDriver.tsx, when serverAuthoritative, run stepPlayerMovement locally anyway, record into the buffer, and on each authoritative frame for the local entity call reconcile, snapping the pose when error exceeds a threshold. Add movement/serverStep(ctx, userId, frame, dt) in core that the host calls per player per tick so the server integrates the same movement. Tests: a pure state machine reconciles to the server state with zero error when inputs match.
-
Session resume. Add graceMs (default 15000) to createGameHost/hostRouter: on socket close keep membership and world alive for the grace window; createWsBackend re-issues join with a ResumeTicket { userId; serverId; token } on reconnect. Test: disconnect and reconnect within the window keeps the player entity.
-
Async world store. Change HostedWorldStore to load(): Promise<HostedWorldRecord | null>; save(record): Promise<void> (keep a sync adapter for tests), make createHostedWorldSession async-aware, and add packages/sql/src/sqlWorldStore.ts and packages/node/src/fileWorldStore.ts. Test each store round-trips. CHANGELOG Migrate.
-
Spectators and relevancy. Add role?: "player" | "spectator" to SnapshotViewer; hosts allow spectator joins that receive snapshots but cannot run movement commands; SnapshotModule gains optional priority(data, viewer) used to send low-priority modules every Nth tick. Flip limits on by default in hostRouter.ts (DEFAULT_COMMAND_LIMITS). Closes #1685.
Rules: wait for bun run agent:bootstrap --check; branch claude/<slug> off origin/main; claim comment first; one task per PR with Refs #1685; bun run gen after export changes (JSDoc on every export; create* factories need snapshot/restore); CHANGELOG bullet; check-types and test on core, ws, node, sql, shell as touched plus check-stateful-ratchet, check-doc-symbols, check-orphan-ratchet; merge origin/main before pushing; squash auto-merge; fix CI on the same branch.
Problem: server-authoritative play has no prediction, interpolation, delta wire, resume, or RTT. Verified on main:
packages/shell/src/drivers/FrameDriver.tsxskips movement,onTick, and behaviors whenserverAuthoritative;packages/ws/src/hostRouter.ts:364pushes the fullgetServerView;packages/core/src/runtime/worldReplication.tsWorldDiffhas no transport;packages/core/src/runtime/hostedWorldSession.ts:21-22HostedWorldStore.load/saveare synchronous; nopingmessage inpackages/ws/src/protocol.ts;packages/shell/src/inputSink.tskeeps one pending frame.Done in #1692:
ctx.sim.tick(),InputFrame.tick,createInputRecorder,createSimSnapshotRegistry.Tasks, one PR each, in order:
Interpolation buffer (core). New
packages/core/src/runtime/snapshotBuffer.ts:createSnapshotBuffer<T>({ delayMs, capacity })withpush(atMs, value),sampleAt(nowMs) -> { before, after, alpha } | null(rendersdelayMsbehind the newest),snapshot,restore,retune. Tests: alpha between two samples, clamps at ends, drops old entries.bun run gen. CHANGELOG Added.Remote players interpolate. In
packages/shell/src/world/WorldScene.tsxRemotePlayers, keep acreateSnapshotBufferper user fed by presence rows and render the sampled pose inuseFrameinstead of the raw row. AddpresenceInterpolationMs?: number(default 100) to the multiplayer presentation config. Clip: two-client local test withexamples/express-hostshowing the remote capsule moving smoothly at 10 Hz presence.Host input queue. In
packages/core/src/runtime/hostedGameRunner.tsreplace the singleinputsmap withcreateInputRecorderper user;tickrunsctx.sim.advanceand for each step feedsrecorder.frameAt(tick)intoctx.game.players.setInput; keepheldInputreturning the newest. Test inhostedGameRunner.test.ts: two frames with ticks 1 and 3 apply on the right ticks.Ping and RTT. Add
{ v: 1; t: "ping"; id: number; at: number }/{ t: "pong"; id; at; serverAt }topackages/ws/src/protocol.ts;createWsBackendsends one per second and keepsrtt: { sampleMs; smoothedMs }on the backend object;hostRouteranswers. Test with the loopback transport.Delta wire. Add
{ v: 1; t: "world"; serverId; frame: { kind: "baseline"; revision; snapshot } | { kind: "diff"; diff: WorldDiff } }to the protocol; inhostRouter.tspushsession.pull(sinceRevision)per subscriber (it already returns baseline or diff) instead ofgetServerView; inpackages/shell/src/worldSync.tsapply diffs throughworldMirrorand request a baseline onneedsResync. Addquantize?: { position: number; angle: number }toReplicationPolicyand round entity positions before diffing. Test: a moving entity produces a diff smaller than the baseline; a client that missed a revision resyncs.Prediction and reconciliation. New
packages/core/src/runtime/prediction.ts:createPredictionBuffer<TState>({ step: (state, input, dt) => TState; maxTicks })withrecord(tick, input, state),reconcile({ tick, state }) -> { replayed: number; error: number }(re-simulate recorded inputs after the authoritative tick),snapshot,restore. Then inFrameDriver.tsx, whenserverAuthoritative, runstepPlayerMovementlocally anyway, record into the buffer, and on each authoritative frame for the local entity callreconcile, snapping the pose whenerrorexceeds a threshold. Addmovement/serverStep(ctx, userId, frame, dt)in core that the host calls per player per tick so the server integrates the same movement. Tests: a pure state machine reconciles to the server state with zero error when inputs match.Session resume. Add
graceMs(default 15000) tocreateGameHost/hostRouter: on socket close keep membership and world alive for the grace window;createWsBackendre-issuesjoinwith aResumeTicket { userId; serverId; token }on reconnect. Test: disconnect and reconnect within the window keeps the player entity.Async world store. Change
HostedWorldStoretoload(): Promise<HostedWorldRecord | null>; save(record): Promise<void>(keep a sync adapter for tests), makecreateHostedWorldSessionasync-aware, and addpackages/sql/src/sqlWorldStore.tsandpackages/node/src/fileWorldStore.ts. Test each store round-trips. CHANGELOG Migrate.Spectators and relevancy. Add
role?: "player" | "spectator"toSnapshotViewer; hosts allow spectator joins that receive snapshots but cannot run movement commands;SnapshotModulegains optionalpriority(data, viewer)used to send low-priority modules every Nth tick. Fliplimitson by default inhostRouter.ts(DEFAULT_COMMAND_LIMITS).Closes #1685.Rules: wait for
bun run agent:bootstrap --check; branchclaude/<slug>offorigin/main; claim comment first; one task per PR withRefs #1685;bun run genafter export changes (JSDoc on every export;create*factories need snapshot/restore); CHANGELOG bullet;check-typesandteston core, ws, node, sql, shell as touched pluscheck-stateful-ratchet,check-doc-symbols,check-orphan-ratchet; mergeorigin/mainbefore pushing; squash auto-merge; fix CI on the same branch.