Recording and replay
Quantum state lives in WASM memory and cannot be serialized directly. QuantumRecorder logs every operation on quantum() handles instead, and replays the log to rebuild the same state later. Use it for save and load, replays, bug reports, and deterministic tests.
Recording a session
The recorder listens through observeQuantum(), so once it has started the game makes no extra calls:
import { ensureLoaded, QuantumRecorder, quantum, measure } from "quantum-forge/quantum";
await ensureLoaded();
const recorder = new QuantumRecorder();
recorder.startRecording();
const a = quantum([false, true]);
const b = quantum([false, true]);
a.superpose();
b.flip({ when: [a.is(true)] });
measure(a, b);
const log = recorder.stopRecording();
const saved = QuantumRecorder.serialize(log); // a JSON stringnew QuantumRecorder() takes no arguments. Passing one, as 2.x code did with a manager, throws a TypeError that points to LegacyQuantumRecorder. See Logs from 2.x.
Replaying a log
const handles = QuantumRecorder.replay(QuantumRecorder.deserialize(saved));
const replayedA = handles.get(a.id); // the replayed a, in the recorded stateReplay creates a fresh handle for every create entry and returns a Map from the id in the log to the new handle. The new handles get new ids of their own, so look them up by the logged id. Handles the log disposes are not in the map: it holds only the handles still live at the end of the log.
Every measurement is replayed as a forced measurement with the recorded outcome. The same gates plus the same outcomes give the same state, including entangled pairs: if a measured true in the session, the replayed a is true too, and so is anything the measurement settled.
If replay throws partway through, it disposes every handle it created before the error reaches you.
Save and load
// SAVE
const saveData = {
gameState: engine.getState(),
quantumLog: QuantumRecorder.serialize(recorder.getLog()),
};
localStorage.setItem("save", JSON.stringify(saveData));
// LOAD
const loaded = JSON.parse(localStorage.getItem("save")!);
const handles = QuantumRecorder.replay(QuantumRecorder.deserialize(loaded.quantumLog));
engine.getHelpers().loadState(loaded.gameState, handles); // put each handle back on its entityYour game state refers to handles by the ids they had when recorded. After replay, use the returned map to attach each new handle to the entity that held the old one. setState is protected on Engine, so expose a helper for restores.
A recorder that is running during replay() records what the replay did: the fresh handles' create entries and the forced measurements. So a game can load a save and keep recording into one log, and that log replays on its own.
The log format
A log is plain JSON, an envelope around a list of entries:
interface QuantumLog {
version: 1;
entries: QuantumLogEntry[];
untrackedIds?: number[]; // present only when the log cannot be replayed
}stopRecording(), getLog(), and deserialize() return this shape. serialize() and replay() take it.
Entries name handles by their id, values by basis index, and operations by their physics name in the WASM spelling. So superpose() is logged as hadamard, flip() and next() as cycle, and iSwap() as i_swap. The session above logs these ops, in order: create, create, hadamard, cycle, measure.
Entry op | Fields | Logged by |
|---|---|---|
create | id, values | quantum() |
dispose | id, outcome | dispose() |
hadamard, cycle, shift, clock, x, y, z | target, fraction?, predicates | Single-property gates and their aliases |
inverse_hadamard | target, predicates | inverseHadamard() |
swap | targets, predicates | swap() |
i_swap | targets, fraction, predicates | iSwap() |
phase_rotate | angle, predicates | phaseRotate() |
measure | targets, outcomes | measure(), the method and the free function |
forced_measure | targets, forced, outcomes | forcedMeasure() |
measure_predicate | predicates, outcome | measureWhen() |
forced_measure_predicate | predicates, forced, outcome | forcedMeasureWhen() |
A gate's fraction is left out when the discrete gate ran. Each predicate is { id, index, isEqual }. Predicate outcomes are 1 when every predicate held and 0 when not.
deserialize() and replay() reject a log without the envelope or with any version other than 1. deserialize() also checks every entry and throws on anything malformed, including a basis index outside its handle's declared values.
Start recording before creating handles
Start recording before you create the handles you want to replay. A handle created earlier has no create entry, so the log has no state to rebuild it from.
When a recorded operation touches such a handle, the recorder warns once for that handle through console.warn, keeps recording, and lists the handle's id in the log's untrackedIds. replay() refuses that log up front with an error naming the ids.
When to start recording
Start at the beginning of a saveable stretch of play, typically when the game begins or a new level loads, before any quantum property exists.
Snapshots while recording
getLog() returns a copy of the log so far without stopping the recording:
recorder.startRecording();
// ... gameplay ...
// Periodic auto-save without interrupting recording
autoSave({ gameState: engine.getState(), quantumLog: recorder.getLog() });
// ... more gameplay ...
const finalLog = recorder.stopRecording();Calling startRecording() while already recording starts over with an empty log. isRecording() tells you whether a recording is running.
QuantumRecorder API
| Member | Description |
|---|---|
new QuantumRecorder() | Create a recorder. Takes no arguments |
startRecording() | Begin recording. Clears the log |
stopRecording() | Stop and return the log |
isRecording() | True between startRecording() and stopRecording() |
getLog() | A copy of the log so far. Works while recording |
QuantumRecorder.serialize(log) | Log to JSON string |
QuantumRecorder.deserialize(text) | JSON string to a validated log |
QuantumRecorder.replay(log) | Rebuild the state; returns Map<loggedId, Quantum> |
Observers
The recorder is built on observeQuantum(), which you can use for your own tooling: a debug overlay, an analytics hook, a tutorial that reacts to the player's first entanglement.
import { observeQuantum } from "quantum-forge/quantum";
const stop = observeQuantum({
onCreate: (prop) => console.log("created", prop.id, prop.values),
onGate: (event) => console.log("gate", event.op),
onMeasure: (event) => console.log("measured", event.op),
onDispose: (prop, value) => console.log("disposed", prop.id, "as", value),
});
// later
stop();Observers see each operation after it succeeds in WASM. Gate and measurement events use the physics op name in the WASM spelling, like the log does, and carry handle ids and basis indices rather than handles. The measurement that dispose() makes is not reported through onMeasure; onDispose carries its value instead.
Observers cannot break the game:
- An error thrown inside an observer is caught and reported through
reportError(), orconsole.errorwhere the runtime lacks it. The other observers still get the event, and the game call that triggered it goes on as normal. - Events arrive in the order the operations ran. An operation run from inside an observer callback is queued, and its event goes out once the current event has reached every observer.
- Each event goes to the observers attached when its operation ran.
- Gate and measurement events are deeply frozen, and every observer gets the same object. Copy one before changing it.
Operations run through handle.raw bypass observers entirely, so the recorder never sees them. A session that mixes them in will not replay correctly.
Logs from 2.x
The 2.x recorder is now LegacyQuantumRecorder. It records operations on a QuantumPropertyManager, and it is the only way to replay a log recorded by 2.x: those logs are bare arrays of operations, and QuantumRecorder rejects them.
import { LegacyQuantumRecorder } from "quantum-forge/quantum";
const legacy = new LegacyQuantumRecorder(manager);
manager.setRecorder(legacy);
legacy.replayLog(oldLog); // a QuantumOperation[] saved by 2.xLegacyQuantumRecorder and QuantumPropertyManager are deprecated. They work through 3.x and go in 4.0. See QuantumPropertyManager (deprecated) for the legacy recorder's API, and Migrating to 3.0 for moving a game over.
Tips
Recording overhead
Recording appends one small object to an array per operation. It costs little, but only enable it when you need save, load, or replay.
Log size
The log grows linearly with the number of quantum operations. A new recording cannot pick up handles that already exist, so you can only start a fresh log at a point where no handle is live, such as between levels.