Recording & Replay
Quantum state lives in WASM memory and cannot be directly serialized. The QuantumRecorder class solves this by logging every state-mutating quantum operation. On replay, the log recreates identical quantum state. Measurements are forced to their recorded outcomes via forced_measure_properties.
Setup
QuantumRecorder is opt-in. Attach it to a QuantumPropertyManager when you need save/load or replay:
import { QuantumRecorder } from "quantum-forge/quantum";
const recorder = new QuantumRecorder(registry);
registry.setRecorder(recorder);Once attached, the recorder automatically logs lifecycle events (acquire, release, setProperty, deleteProperty). For gate operations, record them manually via recorder.recordOp().
Starting a Recording
// Begin recording. Lifecycle hooks log automatically
recorder.startRecording();
// ... gameplay happens ...
// Gate calls need explicit recording:
const m = registry.getModule();
const index = recorder.getIndex(prop);
m.cycle(prop);
if (index !== undefined) {
recorder.recordOp({ op: "cycle", index, fraction: 1 });
}
// Stop and get the log
const log: QuantumOperation[] = recorder.stopRecording();Save / Load
import type { QuantumOperation } from "quantum-forge/quantum";
// SAVE
const saveData = {
gameState: engine.getState(),
quantumLog: recorder.stopRecording(),
};
localStorage.setItem("save", JSON.stringify(saveData));
// LOAD
const loaded = JSON.parse(localStorage.getItem("save")!);
engine.getHelpers().loadState(loaded.gameState); // setState is protected; expose a helper for restores
recorder.replayLog(loaded.quantumLog);
// Quantum state is now identical to the moment of saveHow Replay Works
replayLog() performs these steps:
- Clears all existing properties, ID mappings, and pool on the manager
- Replays each operation in order:
acquire: creates a fresh property or pops from the replay poolcycle,hadamard,i_swap, etc.: applies the gate to recreated handlesmeasure: callsforced_measure_propertieswith the recorded outcomerelease: resets to|0⟩and returns to poolassign/unassign: restores ID-to-handle mappings
- After replay,
registry.getProperty("ball-1")returns the correct handle in the correct quantum state
The key insight: because measurements are forced to recorded outcomes, the quantum state evolves identically. The same gates + same measurement results = same final state.
One consequence of the replay-from-scratch model: start recording before acquiring the properties you want replayed. A handle that already exists when startRecording() runs gets an index (so gates on it can be logged), but the log holds no acquire entry for it, so replayLog() cannot recreate it.
QuantumRecorder API
| Method | Description |
|---|---|
startRecording() | Begin recording; resets log; assigns indices to existing handles |
stopRecording() | Stop recording; return QuantumOperation[] |
isRecording() | Check if recording is active |
getOperationLog() | Get a copy of the log (works even while recording) |
replayLog(ops) | Clear manager state and replay from scratch |
recordOp(op) | Record a gate operation manually |
getIndex(prop) | Get the recorded index for a property handle |
buildWasmPredicates(specs) | Build WASM predicate objects from PredicateSpec[] |
serializePredicates(specs) | Serialize predicates for the operation log |
Lifecycle Hooks (automatic when attached)
| Hook | Triggered by |
|---|---|
onAcquire(prop) | manager.acquireProperty() |
onRelease(prop, value) | manager.releaseProperty(prop, value) |
onSetProperty(id, prop) | manager.setProperty(id, prop) |
onDeleteProperty(id) | manager.deleteProperty(id) |
Operation Types
The QuantumOperation union type covers all state mutations:
type QuantumOperation =
| { op: "acquire"; index: number }
| { op: "release"; index: number; value: number }
| { op: "assign"; index: number; id: string }
| { op: "unassign"; id: string }
| { op: "cycle"; index: number; fraction: number; predicates?: SerializedPredicate[] }
| { op: "shift"; index: number; fraction: number; predicates?: SerializedPredicate[] }
| { op: "clock"; index: number; fraction: number; predicates?: SerializedPredicate[] }
| { op: "hadamard"; index: number; fraction: number; predicates?: SerializedPredicate[] }
| { op: "inverse_hadamard"; index: number; predicates?: SerializedPredicate[] }
| { op: "i_swap"; index1: number; index2: number; fraction: number; predicates?: SerializedPredicate[] }
| { op: "swap"; index1: number; index2: number; predicates?: SerializedPredicate[] }
| { op: "phase_rotate"; predicates: SerializedPredicate[]; angle: number }
| { op: "y"; index: number; fraction: number; predicates?: SerializedPredicate[] }
| { op: "measure"; indices: number[]; outcomes: number[] }
| { op: "measure_predicate"; predicates: SerializedPredicate[]; outcome: number }
| { op: "reset"; index: number; value: number };Every gate variant carries an optional predicates field. Omit it for an unconditional gate. phase_rotate and measure_predicate are the two where predicates are required rather than optional, since a predicate list is the whole point of those operations.
Each WASM property handle is mapped to a sequential integer index for serialization. On replay, fresh handles are created and mapped back.
How replay reads fraction
replayLog() uses a convention worth knowing before you hand-write logs. For cycle, shift, hadamard, and y, an entry with fraction === 1 and no predicates replays as the non-fractional gate. Any other fraction, or the presence of predicates, replays as the fractional gate.
{ op: "cycle", index: 0, fraction: 1 } // replays as m.cycle(prop)
{ op: "cycle", index: 0, fraction: 1, predicates: [...] } // replays as m.cycle(prop, 1, preds)
{ op: "cycle", index: 0, fraction: 0.5 } // replays as m.cycle(prop, 0.5)The consequence: a genuinely fractional gate applied at exactly 1.0, with no predicates, cannot be represented in a log. It records as fraction: 1 and comes back as the discrete gate, which is a different operation. If your game applies fractional gates at continuously varying strengths, clamp away from exactly 1.0 or the replayed state will drift from the recorded one.
clock and i_swap have no such special case. They always replay through the fractional call.
Snapshot vs Continuous Recording
You can get a snapshot of the log without stopping recording:
recorder.startRecording();
// ... gameplay ...
// Periodic auto-save without interrupting recording
const snapshot = recorder.getOperationLog();
autoSave({ gameState: engine.getState(), quantumLog: snapshot });
// ... more gameplay ...
// Final save
const finalLog = recorder.stopRecording();Tips
When to start recording
Start recording at the beginning of a "saveable" state, typically when the game begins or when entering a new level. Everything before startRecording() is lost.
Recording overhead
Recording adds minimal overhead. It's just appending to an array. Only enable it when you need save/load or replay.
Log size
The log grows linearly with the number of quantum operations. For long game sessions, consider periodic checkpoints: stop recording, save the log, start a new recording.