Skip to content

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.

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

bash
npm install quantum-forge@^3.0.0 quantum-forge-engine@^2.0.0

Breaking 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.x3.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 }:

typescript
const enemies = new EntityManager<Enemy>({ disposeQuantum: false }); // the default for every call
enemies.remove("e1", { disposeQuantum: true });                     // or choose per call

Only 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.x3.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] === 1prop.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:

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

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

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

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

typescript
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, and flip = cycle on 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.

Powered by Quantum Forge