Migrating from 2.x to 3.0
quantum-forge 3.0.0 and quantum-forge-engine 2.0.0 replace the property manager with one handle per quantum property. You declare a property by its values, call gates on it as methods, and dispose it when you're done. There is no manager, no getModule() in game code, no pool, and no measured value to hand back.
import { ensureLoaded, quantum } from "quantum-forge/quantum";
await ensureLoaded();
const alive = quantum([false, true]); // a qubit, starts false
alive.superpose(); // alias for hadamard()
alive.probability(true); // 0.5, no collapse
const seen = alive.measure(); // false or true
alive.dispose();Most 2.x code keeps running on 3.0. QuantumPropertyManager still works as a deprecated adapter through 3.x and is removed in 4.0, so you can move one system at a time. Three changes need action before anything else: the recorder, the recorder's log format, and the engine's EntityManager. They are listed first.
Upgrade both packages together
Engine 2.0 requires core ^3.0.0, and its entity manager imports isQuantum from the core at runtime. Upgrade them in one step:
npm install quantum-forge@^3.0.0 quantum-forge-engine@^2.0.0Breaking changes
QuantumRecorder is a new class
The 2.x recorder took a manager. The 3.0 QuantumRecorder records quantum() handles and takes no arguments. new QuantumRecorder(manager) now throws a TypeError that names the replacement.
| 2.x | 3.0 |
|---|---|
new QuantumRecorder(manager) | new LegacyQuantumRecorder(manager) to keep recording manager operations |
new QuantumRecorder() to record handles |
Logs recorded by the 2.x recorder replay only through LegacyQuantumRecorder. See Legacy property manager.
The recorder log is an envelope
The new recorder's log is { version: 1, entries, untrackedIds? }, not a bare array. stopRecording(), getLog() and deserialize() return it; serialize() and replay() take it. deserialize() and replay() reject a log without the envelope and any version other than 1. See Recording & replay.
EntityManager disposes quantum handles
In engine 2.0, an entity that leaves an EntityManager takes its quantum handles with it. remove(), clear(), and add() with an id that is already present all dispose the handles on the entity that leaves. A replacement disposes only the handles the new entity no longer carries.
Disposing measures, so removing an entity collapses every live entity entangled with it. If two entities were split from one enemy, removing one settles whether the other exists.
To keep the old behavior, pass { disposeQuantum: false }:
const enemies = new EntityManager<Enemy>({ disposeQuantum: false }); // the default for every call
enemies.remove("e1", { disposeQuantum: true }); // or choose per callOnly quantum() handles are disposed. Raw WASM properties from a 2.x manager that sit on an entity are left alone.
Moving from the manager to handles
Call by call
| 2.x | 3.0 |
|---|---|
class MyRegistry extends QuantumPropertyManager with super({ dimension: 2 }) | No class. Declare each property with quantum([false, true]) |
const prop = this.acquireProperty() | const prop = quantum([false, true]) |
this.getModule().hadamard(prop) | prop.hadamard() or prop.superpose() |
this.getModule().cycle(prop) | prop.cycle(), prop.next(), or prop.flip() on a qubit |
m.shift(prop) | prop.shift() or prop.previous() |
m.clock(prop, f) | prop.clock(f) or prop.phase(f) |
m.inverse_hadamard(prop) | prop.inverseHadamard() |
m.shift(b, undefined, [a.is(1)]) | b.shift({ when: [a.is(true)] }) |
this.getModule().i_swap(p1, p2, 0.5) | p1.iSwap(p2, 0.5) |
m.swap(p1, p2) | p1.swap(p2) |
m.phase_rotate(preds, angle) | phaseRotate(angle, { when: preds }) |
const [v] = this.getModule().measure_properties([prop]) | const v = prop.measure() |
m.measure_properties([a, b]) | measure(a, b) |
m.forced_measure_properties([a], [1]) | a.forcedMeasure(true) or forcedMeasure([a], [true]) |
m.measure_predicate(preds) | measureWhen(preds), which returns a boolean |
m.predicate_probability(preds) | probabilityWhen(preds) |
this.getModule().probabilities([prop]), then a search for qudit_values[0] === 1 | prop.probability(true) |
this.getModule().reduced_density_matrix([p1, p2]) | densityMatrix(p1, p2) |
this.releaseProperty(prop, value) | prop.dispose() |
this.removeProperty(id) | entity.prop.dispose(), or let EntityManager.remove do it |
this.setProperty(id, prop) / this.getProperty(id) | Store the handle on your entity: entity.prop = prop |
this.clear() | Dispose every live handle |
new QuantumRecorder(manager) | new LegacyQuantumRecorder(manager) for the manager's operations and 2.x logs; new QuantumRecorder() records handles |
The free functions (measure, forcedMeasure, probabilities, densityMatrix, measureWhen, forcedMeasureWhen, probabilityWhen, phaseRotate) import from quantum-forge/quantum, like quantum itself.
Values instead of indices
The 2.x calls returned basis indices. A handle speaks the values you declared, so a property declared as [false, true] measures to false or true rather than 0 or 1. Check comparisons such as value === 1: on a boolean property they are now always false.
Every call that takes a value (is, isNot, probability, forcedMeasure) still accepts the index too, so a.is(1) and a.is(true) build the same predicate. When the declared values are themselves numbers, a number is read as a declared value first. quantum(2) declares the values 0 and 1, which keeps index-based code working as it was.
Predicates
A 2.x predicate was a WASM object from prop.is(1), passed in the third argument, which forced an undefined fraction for the discrete gate. A 3.0 predicate comes from the handle's is() or isNot() and goes in an options object that can stand in place of the fraction:
// 2.x
m.cycle(b, undefined, [a.is(1)]);
// 3.0
b.flip({ when: [a.is(true)] }); // CNOT
b.flip(0.5, { when: [a.is(true)] }); // controlled square root of NOTPredicateSpec objects are not needed with handles.
Fractions
A 3.0 gate takes an optional leading fraction. Omit it for the discrete gate. The handle also treats exactly 1 as the discrete gate, where 2.x m.cycle(prop, 1) ran the fractional gate at 1.0: the same state, by a slower path. inverseHadamard() and swap() take no fraction, and iSwap(other, fraction) requires one.
Reading probabilities
The shapes changed along with the values:
| Call | Returns |
|---|---|
prop.probability(value) | A number |
prop.probabilities() | { value, probability } for every declared value |
probabilities(a, b) | { values, probability } entries, one declared value per property |
densityMatrix(a, b) | { row, col, real, imag } entries, with row and col in declared values |
Lifecycle
releaseProperty(prop, value) needed the measured value to reset a pooled property. dispose() takes nothing. It measures the property, which collapses anything entangled with it. A qudit that is then alone is reset and cached for the next quantum() of the same dimension; one that still shares a state is destroyed, which takes it out of that state. Calling dispose() twice does nothing, and any other call on a disposed handle throws.
A using declaration disposes at the end of a scope. It needs TypeScript 5.2 or newer and "ESNext.Disposable" in the lib of your tsconfig.json. Only code that writes using needs them; projects made by init from engine 2.0 have both. See Lifecycle.
Forced measurement throws on impossible outcomes
forcedMeasure() (method and free function) and forcedMeasureWhen() throw when the forced outcome has zero probability. Replays and tests that forced an outcome the state could not give now get an error instead of a result.
Batch operations
The handle API has no batch call yet. handle.raw is the WASM property behind a handle, and it reaches executeBatch() and executeBatchTape() through getModule(). Operations through .raw are invisible to observers and to QuantumRecorder, you must never call destroy() on it, and it is invalid after dispose(). See Gates.
A worked example
Quantum Pong's split, before and after the port.
2.x:
class QuantumRegistry extends QuantumPropertyManager {
constructor(logger?: LoggerInterface) {
super({ dimension: 2, logger });
}
entangleSplit(originalId: string, newId: string): void {
const prop1 = this.acquireProperty();
const prop2 = this.acquireProperty();
const m = this.getModule();
m.cycle(prop1); // |1⟩ (exists)
m.i_swap(prop1, prop2, 0.5); // entangle
this.setProperty(originalId, prop1);
this.setProperty(newId, prop2);
}
measureExistence(id: string): number {
const prop = this.getProperty(id);
if (!prop) return 1;
const [value] = this.getModule().measure_properties([prop]);
this.deleteProperty(id);
this.releaseProperty(prop, value);
return value;
}
}3.0:
import { quantum, type Quantum } from "quantum-forge/quantum";
interface Ball {
id: string;
exists?: Quantum<boolean>;
}
function entangleSplit(ball: Ball, newBall: Ball): void {
ball.exists = quantum([false, true]).flip(); // exists
newBall.exists = quantum([false, true]); // does not exist
ball.exists.iSwap(newBall.exists, 0.5); // (|10⟩ + i|01⟩)/√2
}
function measureExistence(ball: Ball): boolean {
if (!ball.exists) return true; // classical balls always exist
const value = ball.exists.measure();
ball.exists.dispose();
ball.exists = undefined;
return value;
}The registry class and its id map are gone. Each ball carries its own handle, and measureExistence returns a boolean instead of an index.
What else is new
- Game-word aliases beside the physics names:
superpose=hadamard,next=cycle,previous=shift,phase=clock, andflip=cycleon qubits. observeQuantum()sees every operation on every handle, for tooling. An observer that throws cannot break the game or the other observers.isQuantum()tells a handle from anything else.clearQuantumCache()frees the qudits that disposed handles left for reuse.- In Node, the loader finds the WASM inside the installed package, so headless use needs no base path. See Setup.