Skip to content

[FEATURE] Real-time netcode: tick-indexed input queue, interpolation buffer, prediction and reconciliation, delta wire, session resume, RTT #1685

Description

@Noisemaker111

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions