Skip to content

feat: add action-based game replay system#4633

Open
jsdevninja wants to merge 7 commits into
phase-rs:mainfrom
jsdevninja:feature/replay-system
Open

feat: add action-based game replay system#4633
jsdevninja wants to merge 7 commits into
phase-rs:mainfrom
jsdevninja:feature/replay-system

Conversation

@jsdevninja

Copy link
Copy Markdown
Contributor

Summary

  • Record local/AI games deterministically (header + ordered action sequence, no per-turn snapshots needed -- apply is a pure reducer over a seeded RNG, so replaying the recorded actions reproduces the game exactly).
  • Export/import the recording as a .json file.
  • A new Replay Viewer (/replay) scrubs/plays back through any point in a loaded recording, reusing the existing GameBoard component in read-only "spectate" mode -- no changes to GamePage/GameProvider needed.

Closes #4613.

Design

  • Engine (crates/engine/src/types/replay.rs, crates/engine/src/game/replay.rs): ReplayHeader/RecordedAction/ReplayLog types; reconstruct_initial_state reuses the existing resolve_deck_list + load_and_hydrate_decks + start_game building blocks already shared by the WASM bridge and server-core; ReplayPlayer does sparse checkpoint-cached seek(index) so scrubbing doesn't re-simulate the whole game each time (checkpoints are cheap thanks to the im-backed structural-sharing GameState::clone()).
  • WASM bridge (crates/engine-wasm/src/lib.rs): initialize_game auto-starts a ReplayLog recording from the same inputs it already receives; submit_action appends each successfully-applied action; restore_game_state/resume_multiplayer_host_state/clear_game_state invalidate the in-progress recording (undo desyncs it). New exports: has_replay_recording, export_replay_log, load_replay_for_playback, replay_seek_js, replay_length_js, replay_header_js, clear_replay_playback.
  • Frontend: worker + worker-client RPC plumbing for the new exports; a read-only ReplayAdapter (own dedicated worker, never the live game's); replayStore mirrors the reconstructed state into the shared gameStore with gameMode: "spectate" (already disables all action dispatch via dispatchAction/useCanActForWaitingState); ReplayPage + ReplayControls (scrubber, play/pause, step, speed); an "Export Replay" entry in the in-game Help sheet and a "Watch Replay" link on the home dashboard. Full i18n across all 7 locales.

Out of scope (v1)

  • Multiplayer (phase-server) game recording -- local/AI (WASM) only.
  • Replay sharing infrastructure -- local file export/import only.

Test plan

  • cargo test -p engine (replay module: reconstruction matches live play-through at every recorded index, out-of-order seeking, clamping/empty-log edge cases)
  • cargo test -p engine-wasm (bridge wiring: recording survives export/import round-trip, undo invalidates the recording)
  • cargo check --workspace --all-targets / cargo clippy --workspace --all-targets -- -D warnings
  • pnpm run type-check / pnpm lint / pnpm test -- --run (1701 tests, including i18n key-parity across all locales)
  • Manual: play a local game, export the replay, load it in the Replay Viewer, scrub/play through it

🤖 Generated with Claude Code

@jsdevninja jsdevninja requested a review from matthewevans as a code owner June 30, 2026 04:46

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a deterministic action-based replay system, introducing a stand-alone replay viewer page, playback controls, and a dedicated ReplayAdapter on the frontend, alongside WASM bridge and engine-level support for recording and reconstructing game states from action logs. The review feedback highlights a critical issue where replay desync errors are silently swallowed in the WASM bridge (replay_seek_js), recommending that these errors be propagated as a Result to the frontend to prevent masking critical reconstruction failures.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/engine-wasm/src/lib.rs Outdated
Comment on lines +1377 to +1393
pub fn replay_seek_js(target: u32) -> JsValue {
REPLAY_PLAYER.with(|cell| {
let mut player = cell.take();
let result = match player.as_mut() {
Some(player) => match player.seek(target) {
Ok(state) => to_js(&engine::game::derived_views::ClientGameStateRef::wrap(
state,
Some(PlayerId(0)),
)),
Err(_) => JsValue::NULL,
},
None => JsValue::NULL,
};
cell.set(player);
result
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Replay desync errors are silently swallowed. Evidence: crates/engine-wasm/src/lib.rs:1386.
Why it matters: Swallowing ReplayError::Desync hides critical reconstruction failures from the user, leading to a confusing UI state instead of an error message. This violates the general rule to not swallow unexpected errors.
Suggested fix: Propagate the error by changing replay_seek_js to return a Result and handle it on the frontend.

This change will also require updating client/src/stores/replayStore.ts to catch the error from adapter.seek() and update the UI accordingly. For example:

// client/src/stores/replayStore.ts
seek: async (index) => {
  // ...
  try {
    const state = await adapter.seek(clamped);
    set({ currentIndex: clamped, error: null });
    pushStateToGameStore(state);
  } catch (err) {
    set({ error: err instanceof Error ? err.message : String(err) });
    get().pause();
  }
},
Suggested change
pub fn replay_seek_js(target: u32) -> JsValue {
REPLAY_PLAYER.with(|cell| {
let mut player = cell.take();
let result = match player.as_mut() {
Some(player) => match player.seek(target) {
Ok(state) => to_js(&engine::game::derived_views::ClientGameStateRef::wrap(
state,
Some(PlayerId(0)),
)),
Err(_) => JsValue::NULL,
},
None => JsValue::NULL,
};
cell.set(player);
result
})
}
pub fn replay_seek_js(target: u32) -> Result<JsValue, JsValue> {
REPLAY_PLAYER.with(|cell| {
let mut player = cell.take();
let result = match player.as_mut() {
Some(player) => match player.seek(target) {
Ok(state) => Ok(to_js(&engine::game::derived_views::ClientGameStateRef::wrap(
state,
Some(PlayerId(0)),
))),
Err(e) => Err(JsValue::from_str(&e.to_string())),
},
None => Ok(JsValue::NULL),
};
cell.set(player);
result
})
}
References
  1. Avoid swallowing all errors (e.g., using .ok()) when only specific errors are expected to be handled or ignored. Propagate unexpected errors (such as invariant violations or invalid actions) to prevent masking critical bugs.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Record resolve_all mutations in exported replays.

The recording only appends actions in submit_action, but resolve_all mutates the engine directly through resolve_all_inner. Evidence: submit_action clones action_for_replay and pushes it into REPLAY_LOG after successful apply, while resolve_all calls resolve_all_inner(state, requester, ...) inside with_state_mut; that path applies actions through apply_action_boundary_with_stack_limit without appending them to the replay log. A normal local game that uses Resolve All can therefore export an action log that omits real state transitions, so playback diverges from the game the user actually played. Please either record every applied action from the resolve-all fast-forward path or invalidate/disable replay export after resolve-all mutates state until those actions are captured.

[HIGH] Propagate replay desync errors instead of returning null.

ReplayPlayer::seek returns a Result because seek failure means the recorded action log could not be reconstructed, but the WASM bridge collapses that error into the same null value used for "no replay loaded." Evidence: replay_seek_js catches every Err(_) from player.seek(target) and returns JsValue::NULL; the worker then sends replay_seek_js(msg.target) ?? null, and ReplayStore.seek accepts that value and pushes it into the game store as the new replay state. That makes a corrupt/desynced replay look like a successful seek to an empty state, hiding the exact class of replay bugs this feature needs to expose. Please preserve the Result boundary here: throw/return an error for ReplayError::Desync and only use null for the explicit "no replay loaded" case if that case still needs to be nullable.

[MED] Fail replay load when deck data exists but the card DB is unavailable.

Replay reconstruction silently skips serialized deck data if no card database is present. Evidence: reconstruct_initial_state only hydrates decks when both header.deck_data and db are Some, load_replay_for_playback passes the TLS card DB through unchecked, and the replay adapter swallows card DB load failure. A replay exported with deck data can therefore load into a different initial state instead of failing loudly. Please make replay load fail when header.deck_data.is_some() and no card DB is available.

Adds deterministic recording, export/import, and a scrubbable Replay
Viewer for local/AI games. Reconstruction replays the recorded action
sequence against a state rebuilt from the same RNG seed and deck list
(no per-turn snapshots needed) — see engine::game::replay.

Engine: ReplayHeader/ReplayLog/RecordedAction types, reconstruct_initial_state
(reuses the existing load_and_hydrate_decks/start_game building blocks), and
ReplayPlayer with sparse checkpoint caching for fast scrubbing.

WASM bridge: initialize_game auto-starts a recording, submit_action appends
to it, undo/restore invalidates it; new exports for export/load/seek/clear.

Frontend: worker + worker-client RPC plumbing, a read-only ReplayAdapter,
replayStore driving the existing GameBoard in read-only "spectate" mode
(no GamePage/GameProvider changes needed), a Replay Viewer page with
scrubber/playback controls, and Export Replay / Watch Replay entry points.

Fixes phase-rs#4613

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

# Conflicts:
#	crates/engine-wasm/src/lib.rs
@jsdevninja jsdevninja force-pushed the feature/replay-system branch from 85b0cae to 43d6ce9 Compare June 30, 2026 05:12

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current head still has the same replay-integrity blockers from the previous review. The new set_match_config change is fine, but it does not address the blocking paths below.

[HIGH] resolve_all mutations are still not recorded in exported replays.

submit_action is still the only path that appends accepted actions into REPLAY_LOG, while resolve_all still mutates the live state through resolve_all_inner and returns the derived result without recording the applied actions or invalidating the recording. A game that uses Resolve All can still export a replay missing real state transitions.

[HIGH] Replay seek errors are still swallowed as null.

replay_seek_js still maps every Err(_) from ReplayPlayer::seek to JsValue::NULL; the worker still returns replay_seek_js(msg.target) ?? null, and the replay store still pushes that value as the current replay state. A reconstruction desync is therefore still indistinguishable from a successful seek that produced no state.

[MED] Decked replay load still proceeds without a card database.

reconstruct_initial_state still hydrates header.deck_data only when a card DB is present, and ReplayAdapter.initialize still explicitly swallows card DB load failure because deck data is "simply skipped." That means an exported replay with deck data can still load as an empty-library replay instead of failing.

- Record actions applied during a Resolve All burst into the replay
  recording. resolve_all_fast_forward now returns recorded_actions
  (every apply()-applied action, plus the fast-forward-equivalent
  priority passes seed_remaining_priority_cycle_passes seeds directly
  into state.priority_passes without going through apply()); the WASM
  bridge's resolve_all appends them to REPLAY_LOG. Without this, a
  game that used Resolve All would export a replay missing real state
  transitions.

- Propagate ReplayError::Desync instead of collapsing it into the same
  null used for "no replay loaded". replay_seek_js now returns
  Result<JsValue, JsValue> and throws on desync; the worker lets it
  propagate, and replayStore.seek catches it into a visible error
  state (also stopping playback) instead of silently advancing past a
  corrupted reconstruction.

- Fail replay load loudly when the header carries deck data but no
  card database is available, instead of silently reconstructing a
  different (empty-library) starting state. reconstruct_initial_state
  and ReplayPlayer::load now return Result and error with the new
  ReplayError::MissingCardDatabase variant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@matthewevans

Copy link
Copy Markdown
Member

Backend replay-integrity follow-up looks addressed on this head: resolve_all now drains recorded batch actions into REPLAY_LOG, replay_seek_js preserves reconstruction errors as Result<_, JsValue>, and replay load now fails when serialized deck data needs a missing card database.

I am leaving frontend/UI acceptance for Matt direct review under the current sweep policy, so I am not approving or enqueueing this mixed-surface PR from the automation.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the current head after the earlier replay-integrity fixes. This is materially better than the tournament PR: the feature has a proposal issue, the implementation is mostly at the right seam, and the engine/WASM design is generally reasonable. The replay model lives in engine types/reconstruction, the WASM bridge owns local recording/export, and the frontend reuses the board through a read-only replay adapter instead of duplicating the game UI.

I am still requesting changes before merge.

[MED] Debug/sandbox direct mutations are not recorded or invalidated, so exported replays can desync.

submit_action clones and records normal actions only after apply succeeds, but DebugAction::CreateCard returns early through handle_debug_create_card before that recording path:

  • crates/engine-wasm/src/lib.rs: submit_action branches to handle_debug_create_card(...) for DebugAction::CreateCard.
  • crates/engine-wasm/src/lib.rs: the REPLAY_LOG.push_action(...) path only runs after the later apply(...) call.
  • crates/engine-wasm/src/lib.rs: export_replay_log still serializes the current REPLAY_LOG if present.

Why it matters: sandbox/debug games use the same local WASM state, and the Help sheet exposes replay export for local WASM games. If a user debug-spawns or directly mutates state, the live game state changes but the exported action log omits that mutation. Playback then reconstructs a different game from the one the user exported, which breaks the core promise of deterministic replay.

Please either:

  1. record the debug action in the replay log in a way playback can faithfully re-apply, or
  2. treat debug/direct mutation as replay-invalidating and clear REPLAY_LOG there, the same way restore/undo paths already clear it.

Given this is v1 and debug actions are non-normal gameplay, invalidating the recording is probably the simpler and safer fix.

[LOW] The replay adapter comment is now stale after the missing-card-DB fix.

client/src/adapter/replay-adapter.ts still says reconstruction “degrades gracefully without a card database” and that deck data is “simply skipped.” That was true before the previous review fix, but current engine behavior intentionally fails when a replay has deck data and no card DB is available.

Please update the comment so it matches the actual contract. The current runtime behavior is the right one; the comment is the stale part.

[LOW] Frontend/manual acceptance evidence is still missing.

This PR adds a new user-facing /replay page, a file-import flow, scrub/play/step controls, and a reused GameBoard rendering mode. Static checks passing is useful, but it does not prove the visual/interaction states are acceptable.

Please attach screenshots or a short recording showing at least:

  • export replay from a local/AI game,
  • load it in /replay,
  • scrub and step through several actions,
  • play/pause at different speeds,
  • desktop and mobile-ish layout, or at least confirmation that mobile was checked,
  • error/loading state for an invalid or unloadable replay file.

Also please check off the manual test plan item once done.

CI note

The latest Rust test failure looks like runner/resource pressure rather than a replay assertion failure: the failed shard hit linker bus errors and No space left on device while compiling unrelated test binaries. I would rerun after the changes above, but I am not treating that specific run as evidence that the replay code itself failed a test.

Overall: I do not think this should be closed outright. The architecture is mostly headed in the right direction. I do think the replay log must be made honest for direct mutation paths, and the new UI needs acceptance evidence before this is mergeable.

DebugAction::CreateCard is resolved against the WASM-local CARD_DB and
short-circuits before submit_action's generic apply()-then-record path,
so it was never appended to REPLAY_LOG. A debug/sandbox card spawn
during a recorded game silently desynced the exported replay from the
live state. handle_debug_create_card now invalidates REPLAY_LOG on
spawn, the same way restore_game_state already does for undo.

Split the mutation core into handle_debug_create_card_inner (mirrors
the existing estimate_bracket_inner pattern) so the invalidation has
a real regression test instead of relying on a manual code read — the
wasm_bindgen-facing wrapper's success path calls to_js, which requires
a JS runtime and can't be exercised under plain cargo test.

Also updates the stale replay-adapter.ts comment: reconstruct_initial_state
now fails loudly with MissingCardDatabase when a replay has deck data and
no card DB is loaded, rather than silently skipping deck hydration as the
old comment described.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 5 card(s), 7 signature(s) (baseline: main 68d72894257f)

2 card(s) · ability/Attach · added: Attach (conditional=Equipment changed zones this way, target=you control creature)

Examples: Adaptive Armorer, Masterpiece Vault

2 card(s) · ability/CreateDelayedTrigger · removed: CreateDelayedTrigger (when=when any target enters)

Examples: Adaptive Armorer, Masterpiece Vault

1 card(s) · trigger/Attacks · added: Attacks (active in=battlefield, watches=self)

Examples: Winter Soldier, Reborn Avenger

1 card(s) · replacement/Moved · removed: Moved

Examples: Winter Soldier, Reborn Avenger

1 card(s) · ability/PutCounter · added: PutCounter (conditional=revealed is creature, counter=2 P1P1, target=parent target)

Examples: What Must Be Done

1 card(s) · ability/it · field conditional: land changed zones this way

Examples: Silver Surfer, Cosmic Voyager

1 card(s) · ability/it · removed: it (conditional=revealed is creature)

Examples: What Must Be Done

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Resolve All still exports an impossible replay action order. Evidence: crates/engine/src/game/engine_resolve_batch.rs:111 seeds the non-requester priority pass into recorded_actions before the requester's own pass is applied and recorded at crates/engine/src/game/engine_resolve_batch.rs:137; the regression test now expects [(PlayerId(1), PassPriority), (PlayerId(0), PassPriority)] at crates/engine/src/game/engine_resolve_batch.rs:409, but replay applies recorded actions in order through apply at crates/engine/src/game/replay.rs:169. Why it matters: a replay starting from the original priority state cannot legally submit Player 1's pass first; apply rejects actors outside the authorized submitters with WrongPlayer, so Resolve-All-driven exported replays can still desync. Suggested fix: record fast-forward-equivalent priority passes in the same order a normal replay can legally submit them, or represent the seeded batch transition separately instead of replaying an impossible action sequence.

… order

resolve_all_fast_forward recorded the fast-forward-seeded passes for
other priority-cycle participants before the current actor's own pass,
even though the current actor is the one holding priority in the
original WaitingFor state. A replay reconstructed from that state can
only legally submit the current actor's pass first -- apply() rejects
an action from any actor other than the seat currently waiting, so
replaying the recorded order desynced with WrongPlayer.

seed_remaining_priority_cycle_passes now appends to a caller-local
scratch buffer instead of the shared recorded_actions list. The actual
state mutation order (seed others, then apply() the current actor last
to trigger the full-cycle resolution check) is unchanged -- only the
recorded order changes, so the current actor's action is pushed first
and the seeded ones are appended after.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jsdevninja jsdevninja requested a review from matthewevans June 30, 2026 18:41
@matthewevans

Copy link
Copy Markdown
Member

Backend/current-head re-review on afe0415: the prior Resolve All replay-order blocker is addressed. The recorded batch actions are now in replayable actor order, and the existing replay reconstruction path still applies them through ordered apply calls.

I am leaving frontend acceptance deferred for Matt rather than approving/enqueueing from the sweep.

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.

Add action-based game replay system (record, export, and scrub through past games)

2 participants