Skip to content

Deterministic player sync: shared tick space, stamp-free jump parity, interpolation buffer (Phases 1-4) - #8

Draft
LLThreasher wants to merge 22 commits into
mainfrom
worktree-player-sync-tickspace-impl
Draft

Deterministic player sync: shared tick space, stamp-free jump parity, interpolation buffer (Phases 1-4)#8
LLThreasher wants to merge 22 commits into
mainfrom
worktree-player-sync-tickspace-impl

Conversation

@LLThreasher

@LLThreasher LLThreasher commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Deterministic player sync — the full plan (PLAYER_SYNC_IMPL_PLAN.md) implemented through Phase 4.

Status

Phase State Notes
1 — shared 20 Hz tick space, tick-stamped movement frames, ray-encoded actions SimTickContext, 3 sub-steps of 1/60 per tick, TryReadTickFrame exact-tick contract
2 — single-cadence deterministic player sim from one shared config player_sim_config.hpp builders; no hand-assembled stage pushes
3 — parity gate → jump stamp deleted impulse defect fixed at the root (emplace-order on the wire), arcs bit-identical, 276 lines of stamp code removed
4 — interpolation buffer for the remote copy two-world InterpolationLayer, exponential attractor k = 12/s, local player exempt

Highlights

  • Jump parity without a stamp: with the shared tick space + config, every
    stream re-derives the jump from its own physics over the shared frame; verified
    vy/y bit-identical across the wire. Root cause of the old 3% arc mismatch:
    CreatePlayer mutated components after emplace, so on_construct shipped
    struct defaults.
  • Interpolation: the remote copy's rendered transform follows the
    authoritative mirror through an exponential attractor — a 100+ m server
    teleport converges over ~0.3 s instead of jumping on screen
    (e2e_remote_copy_no_teleport, verified red against the old lerp).

Two documented plan deviations

  • T4 absolute per-tick bounds → shape-relative parity (the Phase 3 mechanism fix
    removed the per-tick re-anchor those bounds assumed; a constant ~0.28 m
    horizontal offset from the 60 Hz aim drain is the accepted feel tradeoff).
  • T6's 0.3 m/frame bound → attractor-contract bound (0.3 m is unattainable
    alongside k ≈ 12/s for a 100+ m snap).

Verification

  • Full build green (incl. Arterium.app, game_server).
  • Full ctest 151/151; e2e binary 19/19 three times.
  • Phase 3/2 grep gates clean.
  • Release-reset fix (2026-08-15, post-review): dig/place release resets the
    action cooldown (main parity, user-reported regression) — mask-0 release
    actions ride the replicated stream, ApplyRayAction zeroes lastActionTime
    before the cooldown gate. Red-checked against the exact pre-fix semantics
    (e2e_dig_cooldown_release_reset).

Remaining: manual smoke (no display in the dev environment) and Phase 5
(reconciliation by re-simulation) — explicitly out of scope, noted in HANDOFF.md.

🤖 Generated with Claude Code

@LLThreasher LLThreasher changed the title Phase 1: shared 20 Hz tick space, tick-stamped movement frames, ray-encoded actions Deterministic player sync: Phase 1 + Phase 2 (shared tick space, parity sim, stamped jumps) Aug 14, 2026
@LLThreasher LLThreasher changed the title Deterministic player sync: Phase 1 + Phase 2 (shared tick space, parity sim, stamped jumps) Deterministic player sync: shared tick space, stamp-free jump parity, interpolation buffer (Phases 1-4) Aug 14, 2026
@LLThreasher
LLThreasher changed the base branch from dev/player-sync-tickspace to main August 14, 2026 14:13
LLThreasher added a commit that referenced this pull request Aug 14, 2026
…/main (98327ce), conflict-free

Co-Authored-By: Claude <noreply@anthropic.com>
LLThreasher added a commit that referenced this pull request Aug 15, 2026
…tus block

Co-Authored-By: Claude <noreply@anthropic.com>
LLThreasher and others added 18 commits August 15, 2026 15:21
…ace, dual-cadence client sim, ray-encoded actions)

Co-Authored-By: Claude <noreply@anthropic.com>
Current data flow, correctness invariants, measured numbers, and open
problems (config-unification flake root-cause hypothesis + tentative
terrain-split fix, double-integration, tag duplication).

Co-Authored-By: Claude <noreply@anthropic.com>
…, ray-encoded actions

- Shared SimTickContext: server advances every kSubStepsPerTick=3 polls;
  client advances on fixed frames and snaps ahead to serverTick +
  snapshotInterval (D1/D3).
- Tick-stamped movement frames: PlayerInputFrame carries its producing
  tick; TryReadTickFrame applies frames at simTick - 1 with stale-skip
  before due-match, bounded wait + gap degrade (kMaxInputWaitTicks=8).
- Ray-encoded dig/place actions (D6): the client's realtime stage bakes
  the ray from the live camera (chased before emission); the server casts
  the same ray — no camera reconstruction, no local terrain edits.
- Rollback lifecycle fix: genuine post-sim predictions inserted after
  Scene::Update, prediction-window admission guard in ValidateLatest, and
  prediction re-anchor to the authoritative mirror after rollback (prevents
  stale-prediction re-insertion loops).
- EraseComparedPredictions boundary parameterized: Validate erases <=
  alignment, ValidateLatest erases <= alignment - 1 (one-tick deferral).

Verified: replication_events_test 64/64, e2e_scene_test 16/16, full
ctest 124/124.

Co-Authored-By: Claude <noreply@anthropic.com>
…ed config

- player_sim_config.hpp/.cpp: shared client sim-config builders — the server
  scene and the client synthesize the same fixed-stage list (single source
  of truth for both cadences)
- client_scene2.hpp: authoritative mirror world runs the full server fixed
  stage list as a 20 tps parity sim; prediction world re-anchors to it each
  tick; realtime creature/physics stages filter to LocalPrediction-tagged
  bodies; full player component set replicates to the mirror
- subsystem_player.cpp: jump-stamp application gated per arc (stampActive,
  resets on landing) — repeat stamps no longer re-anchor a running arc
- subsystem_creature/physics: realtime variants take the LocalPrediction
  render strategy filter so remote copies stay inert on the prediction world
- e2e_scene_test.cpp: jump test measures the arc anchor directly — the
  mirror's lift position vs the client-stamped lift-off spot (pred-vs-auth
  raw positions differ by a tick of run motion by construction); per-tick
  arc sampling; harness client tags the local player LocalPrediction like
  DebugVoxelView
- full suite green: 144/144

Co-Authored-By: Claude <noreply@anthropic.com>
…ss dt

- CreatePlayer: mutate to final values BEFORE emplace so the on_construct
  replication payload carries initJumpSpeed (1.65 m jump, not the 1.55
  struct default) and stepAssist (1.01, not 0.01).  The client was
  faithfully simulating the replicated defaults — a 3% shorter jump arc
  (pred 5.51 vs server 5.69 m/s impulse) and no step assist.
- scene_test_harness: POLL_DT = 1/60 exactly (kSubStepDt) — any other
  poll dt diverges the client's realtime body integration from the
  server's fixed substeps on dt mismatch alone.
- Phase-3 mechanism tail: prediction no longer re-anchors to the mirror
  each tick (its continuous sim is the parity derivation; anchor only
  after rollback), predicted physics inserted post-sim, stamp gated off
  (kUseJumpStamp = false).
- Temporary PFX/CRT/PHYG/PUSH-SKIP diagnostics kept for the remaining
  arc-parity verification — removed before M3 closes.

Co-Authored-By: Claude <noreply@anthropic.com>
…nostics

The Phase 3 parity gate verified that with the shared tick space and
sim config, every stream re-derives the jump from its own physics over
the shared frame.  The client-decided stamp is now gone:

- net.hpp: HasJumpStamp flag, jumpX/Y/Z fields, pack/unpack arms, and
  the DECL_NET_OBJ stamp arm removed — the jump input flag alone rides
  the wire (12 bytes lighter per stamped frame).
- player_input_stream.hpp: kUseJumpStamp, jumped/jumpPos on both frame
  types, MarkJumpPerformed/m_pendingJump/m_jumpPos, the AdvanceTick
  stamp commit, and PlayerSimInputState::stampActive removed.
- subsystem_player.cpp: kMaxJumpStampDelta + the anchored-impulse branch
  + the local-jump MarkJumpPerformed hook + the PFX diagnostic removed;
  the fixed stage now applies frame.jump unconditionally (parity path).
- replication_events.hpp: PUSH-SKIP diagnostics removed.
- subsystem_creature.cpp / subsystem_physics.cpp: CRT / PHYG-PHYR
  diagnostics removed.
- replication_events_test.cpp: stamp round-trip checks rewritten to the
  jump-flag-only wire; aggregate_tick_stamp_move renamed to
  aggregate_tick_move.

Verified: full build green, replication_events_test 64/64, e2e 17/17
twice, full ctest 144/144.  Grep gate (HasJumpStamp|jumpPos|
MarkJumpPerformed|kUseJumpStamp|stampActive) has no hits.

Co-Authored-By: Claude <noreply@anthropic.com>
…ttractor)

InterpolationLayer now implements the Phase 4 two-world contract:

- PreUpdate(const GameWorld& authoritative): snapshots the authoritative
  mirror's ComponentPhysicBody per Interpolation-tagged entity into a
  bounded per-entity history with arrival ticks.
- PostUpdate(GameWorld& renderWorld, alpha, dt): writes
  ComponentInterpolatedTransform via an exponential attractor
  (k = 12/s) toward the extrapolated authoritative state — latest
  snapshot position + velocity x (alpha x kFixedFrameDuration) — so a
  server correction converges over ~0.3 s instead of teleporting the
  copy.
- Local player exempt: LocalPrediction-tagged entities are skipped —
  the prediction-world body IS the local render state (zero delay).
- SceneView passes the two worlds; scenes without a mirror (base
  Scene::GetAuthoritativeWorld returns nullptr) fall back to their own
  world.

Tests (new e2e_remote_copy_no_teleport + extended
e2e_interpolation_layer) were verified RED against the old
single-world lerp (16/2: the transform teleported with the body snap,
and the local player got a transform despite the exemption), then
green with the attractor.

The plan's T6 |interpDelta| < 0.3 m bound is mathematically
incompatible with k = 12/s for a 100+ m snap (first-frame response is
(1 - e^(-12/60)) ≈ 0.18 of the gap); the test instead asserts the
attractor contract — documented in the test comment.

Verified: full build green, e2e 18/18 three times, full ctest 145/145.

Co-Authored-By: Claude <noreply@anthropic.com>
HANDOFF.md rewritten as the post-implementation state: the tick space /
input contract / jump parity / interpolation layer as built, the two
documented plan deviations (T4 bounds, T6 bound), remaining work, and
verification numbers.  PLAYER_SYNC_IMPL_PLAN.md gains an
implementation-status block pointing at it.

Co-Authored-By: Claude <noreply@anthropic.com>
…/main (98327ce), conflict-free

Co-Authored-By: Claude <noreply@anthropic.com>
…ctx, drains actions

A bare game::Scene (no server/client scene) never emplaces
sim::SimTickContext, so SubsystemPlayer<FixedStep>'s ctx().get threw
(entt dense_map::at) on the first fixed frame with a live player
(SIGABRT, exit 134).  Mirror SubsystemPhysics's bare-world fallback:
skip the tick decisions and drain the PlayerActionStream accumulation
directly (AggregateTick + ApplyRayAction) — standalone scenes have no
PollPlayerActions, so the 3-slot accumulator overflowed ("player action
overflow" warning) and dig/place actions never removed blocks.

Gated by scene_standalone_player_sim_smoke and
scene_standalone_player_dig_action (scene_load_test, 3 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
…ne and networked scenes

- SimTickContext is guaranteed in every scene world at Scene construction
  (the runner Updates scenes without ever Load()ing them, so a Load-only
  guarantee left ClientConnScene's world without a tick ctx and the fixed
  block aborted on its second Update)
- Scene::Update owns the fixed loop (kSubStepDt sub-steps, subStepIdx
  boundaries) with tick arbitration: transport scenes write currentTick
  from their replication tick; when unchanged, the scene advances it and
  aggregates local input (sim::AggregateLocalInputs) with the same
  T - kInputPipelineDelayTicks stamp the transport pollers use
- SubsystemPlayer<FixedStep> reads SimTickContext + PlayerSimInputState
  unconditionally; the bare-world fallback branch is deleted
- scene_standalone_player_jump gates the jump path end-to-end

Co-Authored-By: Claude <noreply@anthropic.com>
GetDefaultSceneConfig put the fixed trio AND the realtime trio in the
standalone scene — both pipelines integrated the player body (fixed 30 Hz
+ realtime 60 Hz) and movement ran ~2x fast.  The realtime pipeline now
hosts the player stage only (camera chase, raw-frame drain, ray baking);
the fixed trio owns movement, jump and physics.

scene_standalone_player_jump's apex gate tightens 0.4 m -> 1.2 m as the
single-integration teeth check (the double-sim config peaked at ~0.83 m).

Co-Authored-By: Claude <noreply@anthropic.com>
Stage carries its concrete type id (set by the factory add path);
both AddStage overloads skip a second registration of the same id with
a warning — a double add (double Load, duplicated config list) would
run the stage twice per update.

staged_scheduler_duplicate_stage_guard gates the guard: the duplicate
add returns nullptr and the stage runs exactly once per update.

Co-Authored-By: Claude <noreply@anthropic.com>
…giness

After the default config stopped double-simming the body, standalone
physics ran on the fixed trio at the scene's old 1/30 fixed-frame default
(2 sub-steps) — the body updated at 30 Hz while rendering at 60, so
movement regressed to a jaggy version of main's per-frame realtime path.

The bare scene's fixed-frame duration is now sim::kSubStepDt (one
sub-step, 60 Hz): the fixed physics integrates every frame, the subStepIdx
0 decision gate opens every frame, and the body sim stays single (fixed
trio only).  Transport scenes keep their explicit 1/20 override.

Co-Authored-By: Claude <noreply@anthropic.com>
The pre-ray path zeroed player.lastActionTime on every event whose
non-jump mask was 0, so releasing dig/place unthrottled the next press.
The ray-encoded rewrite skipped mask-0 events entirely — the cooldown
never reset on release (user-reported regression).

Release now rides the replicated action stream as a mask-0 action: the
realtime stage emits one per dig/place-unset event, and ApplyRayAction
zeroes the timer before the cooldown gate.  Deterministic on both fixed
pipelines (no realtime-stage timer writes).

Tests: action_stream_release_frames (unit) pins the release/empty-tick
contract; e2e_dig_cooldown_release_reset digs twice inside the 6-tick
cooldown window (verified red against the pre-fix semantics — dig2
dropped) and e2e_player_input_prediction_vs_authoritative now scans for
the dig action among releases.

Co-Authored-By: Claude <noreply@anthropic.com>
#8, commit table, plan status block

Co-Authored-By: Claude <noreply@anthropic.com>
@LLThreasher
LLThreasher force-pushed the worktree-player-sync-tickspace-impl branch 2 times, most recently from 61aaa9a to 3e39cec Compare August 15, 2026 07:58
LLThreasher and others added 2 commits August 15, 2026 16:12
…n both sides

The client's first handshake packet now starts with net::kProtocolVersion (2)
and the server's reply echoes it: the server rejects mismatched/truncated
first packets before CreatePlayer, the client aborts on a mismatched/short
reply, and the server logs its version at startup.  Overridable per scene
via the protocol_version arg; e2e_handshake_version_mismatch_rejected pins
the rejection (20/20 e2e, 152/152 ctest).

Co-Authored-By: Claude <noreply@anthropic.com>
…LAUDE.md contract, plan status block

Co-Authored-By: Claude <noreply@anthropic.com>
@LLThreasher
LLThreasher force-pushed the worktree-player-sync-tickspace-impl branch from 3e39cec to bcf6800 Compare August 15, 2026 08:15
LLThreasher and others added 2 commits August 15, 2026 18:17
…rift

AggregateLocalInputs now pushes the PackedPlayerInputFrame SNorm8
round-trip into the local tick ring, matching PollPlayerInputs: the
fixed stages consume bit-identical frames with or without a transport
layer.  Diagonal moves quantize inexactly (±0.7071 → ±90/127), so a
diagonal raw frame and its tick frame differ by the wire's
quantization — the per-axis bias behind the S+D error accumulation.

The 60 Hz realtime prediction stage keeps draining raw frames (by
design — it diverges by cadence anyway).

New test: aggregate_local_inputs_quantizes_move pins the contract.

Co-Authored-By: Claude <noreply@anthropic.com>
… standalone contract

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant