Lifecycle and state management
A quantum property lives from quantum() to dispose(). This page covers what disposing does, how the engine's EntityManager disposes handles for you, how to watch the size of the quantum state, and how to run an isolated simulation for search.
Disposing a property
dispose() is the only lifecycle call:
import { ensureLoaded, quantum } from "quantum-forge/quantum";
await ensureLoaded();
const coin = quantum([false, true]).superpose();
const heads = coin.measure();
coin.dispose();dispose() measures the property first, which also collapses every property entangled with it. What happens next depends on whether the qudit is alone in its state:
- Alone:
dispose()resets it and keeps it in an internal cache. The nextquantum()with the same number of values reuses it instead of allocating a new one. - Still sharing a state with other properties:
dispose()destroys it, which takes it out of that state. It is not cached.
Either way the next quantum() starts from a qudit of its own, never one inside an old group, and a disposed property stops counting against the qudit limit.
You do not pass dispose() a value. Calling it twice does nothing, and any other call on a disposed handle throws Quantum property #N was disposed and can no longer be used. dispose() also works on a handle frozen with Object.freeze(), which deep-freeze helpers on game state sometimes do.
A measured handle is not disposed. After measure() it holds a definite value and can go back into superposition with another gate. Only dispose() ends its life.
Disposing collapses partners
Because disposing measures, disposing one half of an entangled pair settles the other half:
const a = quantum([false, true]).flip(); // a starts true
const b = quantum([false, true]); // b starts false
a.iSwap(b, 0.5); // exactly one of them is true
a.dispose();
// b is now definitely true or definitely false, and a qudit of its own again
b.numActiveQudits(); // 1If you want the outcome, call measure() before dispose(). dispose() does not return it.
using declarations
A using declaration disposes the handle when the scope ends, including when it ends by a throw:
function rollDie(): number {
using die = quantum(3).superpose();
return die.measure();
} // die.dispose() runs hereusing needs TypeScript 5.2 or newer and "ESNext.Disposable" in the lib of your tsconfig.json. Projects made by npx quantum-forge-engine init have both. See Setup: TypeScript and using.
Handles on entities
Store a handle on the entity it describes. The engine's EntityManager disposes quantum handles when it removes an entity, so most games never call dispose() themselves:
import { ensureLoaded, quantum, type Quantum } from "quantum-forge/quantum";
import { EntityManager } from "quantum-forge-engine/entities";
await ensureLoaded();
interface Enemy {
id: string;
x: number;
y: number;
alive: Quantum<boolean>;
}
const enemies = new EntityManager<Enemy>();
enemies.add({ id: "e1", x: 10, y: 20, alive: quantum([false, true]).flip() });
enemies.remove("e1"); // disposes enemy.aliveWhat the manager disposes:
remove()andclear()dispose the handles on each entity that leaves.add()with an id that is already present replaces the entity and disposes the old entity's handles that the new one no longer carries.
What it finds: handles stored as own enumerable properties of the entity, under a string or a symbol key, and handles inside an array stored in one. Nothing else is walked: not nested objects, Map or Set contents, arrays of arrays, non-enumerable properties, or #private fields. Getters are never called, so a handle behind one is not found. Dispose those handles yourself.
Disposing measures, so removing an entity collapses whatever its handles were entangled with. If e1 and e2 were split from one enemy, enemies.remove("e1") settles whether e2 exists.
A handle belongs to one entity. If two entities hold the same handle, removing either one disposes it, and the other's next use of it throws.
Keeping a handle past its entity
To keep a handle after its entity goes, for a replay or a score screen, move it off the entity first, or pass { disposeQuantum: false } to that remove(), clear(), or add() call:
enemies.remove("e1", { disposeQuantum: false }); // e1's handles stay livenew EntityManager({ disposeQuantum: false }) turns the default off for the whole manager. Handles you keep this way are yours to dispose.
Engine 2.0
Automatic disposal is new in engine 2.0. In 1.x, EntityManager never touched quantum state. See Migrating to 3.0.
State budget queries
Every property you interact with joins a shared state, and that state can grow as large as dimension ^ qudits entries. Two methods on the handle report how big the state it belongs to is:
const numQudits = prop.numActiveQudits(); // qudits in this property's shared state
const sparseSize = prop.stateVectorSize(); // basis amplitudes stored for that stateBoth are read-only and cheap enough to call every frame.
| Query | Returns | Use case |
|---|---|---|
prop.numActiveQudits() | Number of qudits in the property's shared state | Check entanglement group size |
prop.stateVectorSize() | Current number of basis amplitudes (sparse) | Monitor memory pressure |
getMaxQudits() | Most qudits one shared state may hold: 12 (Qutrit) or 20 (Qubit) | Compare against numActiveQudits() |
getMaxStateSize() | Most basis amplitudes one state vector may hold: 100,000 | Compare against stateVectorSize() |
A property that has never interacted with another reports one qudit. After a CNOT with another qubit, both report two. When one of them is disposed, the other drops back to one.
Checking before an interaction
An interaction that would push a shared state past the qudit limit throws. Check capacity before the interaction instead of catching the throw: each caught throw leaks WASM memory, and after enough of them every later call fails.
import { getMaxQudits, quantum, type Quantum } from "quantum-forge/quantum";
interface Ball {
exists?: Quantum<boolean>;
}
/** Split an already-quantum ball, or stay classical if its state is full. */
function quantumSplit(original: Ball, clone: Ball): boolean {
if (!original.exists) return false;
if (original.exists.numActiveQudits() >= getMaxQudits()) return false; // state is full
const split = quantum([false, true]);
original.exists.iSwap(split, 0.5);
clone.exists = split;
return true;
}On the Qutrit Edition the amplitude limit can bite first. A group of fully superposed qutrits passes 100,000 amplitudes at 11 qudits, one short of the 12-qudit limit, and the interaction throws [QuantumForgeStateSizeError]. If your game entangles many qutrits, compare stateVectorSize() against getMaxStateSize() as well. See Error handling.
The internal cache
clearQuantumCache() destroys every cached qudit and empties the cache. Live handles are not affected. Tests use it to start from a clean slate, and a game can call it between scenes to free memory.
import { clearQuantumCache } from "quantum-forge/quantum";
clearQuantumCache();The raw WASM property
handle.raw is the WASM property behind a handle. It exists for the batch APIs (executeBatch, executeBatchTape), which have no handle form yet. Three rules:
- Operations through
.rawbypass the handle, so observers andQuantumRecordernever see them. A recorded session that mixes them in will not replay correctly. - Never call
destroy()on it. The handle owns the property, and itsdispose()fails after one. - It is valid only while the handle is live. Reading
.rawon a disposed handle throws, and a reference kept from earlier may already back a different handle.
QuantumSimulation
QuantumSimulation creates an isolated simulation context for search, as quantum chess does. It works on raw WASM properties, not quantum() handles: quantum() always creates properties in the global context. Properties created within a simulation can interact with each other but not with properties from another simulation.
import { getQuantumForge, getModule, ensureLoaded } from "quantum-forge/quantum";
await ensureLoaded();
const sim = getQuantumForge().createSimulation();
const prop1 = sim.createProperty(2); // two values
const prop2 = sim.createProperty(2);
// Gates come from the module, not from the simulation
const m = getModule();
m.hadamard(prop1);
m.i_swap(prop1, prop2, 0.5); // works, same simulation
// Clean up. Releases all properties and state vectors at once
sim.destroy();A simulation owns properties, not gates. Its whole surface is createProperty, destroyProperty, factorizeAllSeparable, destroy, and isDestroyed. Gates and queries come from getModule() and use the WASM names (i_swap, measure_properties, and so on). They work on properties from any simulation as long as a single call does not mix two simulations.
Why isolated simulations?
Search tree exploration needs a fresh quantum state per branch. Without isolation, every replayed branch shares, and grows, the same state vector:
// Each replay is isolated
for (const move of candidateMoves) {
const sim = getQuantumForge().createSimulation();
const props = replayUpTo(move, sim); // properties are sandboxed
const score = evaluate(props); // state vector holds just this replay
sim.destroy(); // everything released at once
}Cross-simulation entanglement is an error
A gate across two simulations throws:
const simA = getQuantumForge().createSimulation();
const simB = getQuantumForge().createSimulation();
const propA = simA.createProperty(2);
const propB = simB.createProperty(2);
getModule().i_swap(propA, propB, 0.5);
// Error: [QuantumForgeError] Cannot entangle properties from different QuantumSimulation contextsIf you match on the message, match on that exact text. It is the string the C++ layer throws.
Simulation reference
| Method | Description |
|---|---|
getQuantumForge().createSimulation() | Create a new isolated simulation |
sim.createProperty(dimension) | Create a property bound to this simulation |
sim.destroyProperty(prop) | Take one property out and free its resources |
sim.factorizeAllSeparable() | Split out any separable qudits across all shared states |
sim.destroy() | Release all properties and state vectors at once |
sim.isDestroyed() | Whether destroy() has already been called |
Incremental property destruction
For search algorithms that create many helper properties, sim.destroyProperty(prop) frees them one at a time instead of waiting for sim.destroy():
const sim = getQuantumForge().createSimulation();
const board = sim.createProperty(2);
const ancilla = sim.createProperty(2);
const m = getModule();
m.i_swap(board, ancilla, 0.5);
// ... use the entanglement ...
m.i_swap(board, ancilla, -0.5); // undo
// ancilla is separable again, so destroying it leaves board untouched
sim.destroyProperty(ancilla);destroyProperty also splits out any qudits that became separable once the destroyed property is gone. That keeps qudits from piling up across many do/undo search cycles.
Destroying entangled properties
If the property is still entangled with others, destroying it measures it, which collapses its partners. A console warning is emitted in this case. Undo the entanglement before destroying to avoid the collapse.
After undo operations that may have restored separability, such as i_swap(a, b, f) followed by i_swap(a, b, -f), call sim.factorizeAllSeparable() to split separable qudits into independent states without destroying anything.
After sim.destroy(), every property created in the simulation is invalid. Check prop.is_valid() if you still hold references.
Lifecycle summary
| Tool | Purpose | When to use |
|---|---|---|
dispose() or using | Measure, then cache or destroy the qudit | Whenever a property's object is gone |
EntityManager | Dispose handles on entities it removes | Games built on the engine |
| State budget queries | Watch shared state size | Before interactions, debug overlays |
clearQuantumCache() | Free cached qudits | Tests, scene changes |
QuantumSimulation | Isolated raw WASM contexts | Search trees, replay branches |