You're reading the 2.x docs. quantum-forge 3.0 is out: this page in 3.x / migration guide

Skip to content

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:

typescript
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 ​

typescript
// 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 ​

typescript
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 save

How Replay Works ​

replayLog() performs these steps:

  1. Clears all existing properties, ID mappings, and pool on the manager
  2. Replays each operation in order:
    • acquire: creates a fresh property or pops from the replay pool
    • cycle, hadamard, i_swap, etc.: applies the gate to recreated handles
    • measure: calls forced_measure_properties with the recorded outcome
    • release: resets to |0⟩ and returns to pool
    • assign / unassign: restores ID-to-handle mappings
  3. 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 ​

MethodDescription
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) ​

HookTriggered 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:

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

typescript
{ 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:

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

Powered by Quantum Forge