Deterministic player sync: shared tick space, stamp-free jump parity, interpolation buffer (Phases 1-4) - #8
Draft
LLThreasher wants to merge 22 commits into
Draft
Conversation
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>
…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>
…per-tick bounds, apex skew < 0.05
…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>
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
force-pushed
the
worktree-player-sync-tickspace-impl
branch
2 times, most recently
from
August 15, 2026 07:58
61aaa9a to
3e39cec
Compare
…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
force-pushed
the
worktree-player-sync-tickspace-impl
branch
from
August 15, 2026 08:15
3e39cec to
bcf6800
Compare
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Deterministic player sync — the full plan (
PLAYER_SYNC_IMPL_PLAN.md) implemented through Phase 4.Status
SimTickContext, 3 sub-steps of 1/60 per tick,TryReadTickFrameexact-tick contractplayer_sim_config.hppbuilders; no hand-assembled stage pushesInterpolationLayer, exponential attractor k = 12/s, local player exemptHighlights
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:
CreatePlayermutated components after emplace, soon_constructshippedstruct defaults.
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
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).
alongside k ≈ 12/s for a 100+ m snap).
Verification
Arterium.app,game_server).action cooldown (main parity, user-reported regression) — mask-0 release
actions ride the replicated stream,
ApplyRayActionzeroeslastActionTimebefore 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