First Quantum Game
Build a game where objects enter superposition and collapse on click. This tutorial covers the whole flow from scaffold to quantum measurement, and every snippet is meant to be pasted in as written.
What we're building
A field of circles. Click one to put it in superposition (it fades to a ghost). Click again to measure it. It either solidifies or vanishes. Shift-click two circles to build an entangled pair where exactly one of the two survives.
Step 1: Scaffold
npx -y quantum-forge-engine init quantum-circles \
--template starter --platforms web --edition qutrit --no-claude-skill
cd quantum-circlesThe CLI lives in the engine package. npx quantum-forge init works too from core 2.7.0 onward; older core releases ship no executable.
Passing every flag skips the prompts, which is what you want here. Drop the flags if you'd rather answer the questions.
Step 2: Define state
Replace src/engine/GameEngine.ts:
import { Engine } from "quantum-forge-engine/engine";
export interface Circle {
id: string;
x: number;
y: number;
radius: number;
color: string;
isQuantum: boolean;
existenceProbability: number;
}
export interface GameState {
circles: Circle[];
selected: string | null;
}
function createCircles(): Circle[] {
const circles: Circle[] = [];
for (let i = 0; i < 8; i++) {
circles.push({
id: `circle-${i}`,
x: 100 + (i % 4) * 150,
y: 150 + Math.floor(i / 4) * 150,
radius: 40,
color: "#a855f7",
isQuantum: false,
existenceProbability: 1.0,
});
}
return circles;
}
export class GameEngine extends Engine<GameState> {
constructor() {
super({ circles: createCircles(), selected: null });
}
reset(): void {
this.setState({ circles: createCircles(), selected: null });
}
getHelpers() {
return {
setQuantum: (id: string, isQuantum: boolean, prob: number) => {
const state = this.getState();
this.setState({
...state,
circles: state.circles.map((c) =>
c.id === id ? { ...c, isQuantum, existenceProbability: prob } : c,
),
});
},
updateProbability: (id: string, prob: number) => {
const state = this.getState();
this.setState({
...state,
circles: state.circles.map((c) =>
c.id === id ? { ...c, existenceProbability: prob } : c,
),
});
},
removeCircle: (id: string) => {
const state = this.getState();
this.setState({
...state,
circles: state.circles.filter((c) => c.id !== id),
});
},
select: (id: string | null) => {
this.setState({ ...this.getState(), selected: id });
},
};
}
}Two rules the base class enforces, and both bite if you ignore them:
getHelpers() and reset() are abstract. Leave reset() out and TypeScript fails the subclass with TS2515 before you ever load the page.
getState() returns Readonly<TState>. Assigning to a field on it is a TS2540 error, so every helper builds a new object and hands it to setState. That's why setQuantum maps over the array instead of finding a circle and editing it.
Step 3: Replace the starter test
The starter ships src/game.test.ts written against its own state shape, with a describe("GameEngine") block that reads state.player and state.score. Those fields no longer exist, and npm run build runs tsc over everything, so a stale test breaks the build rather than just the test run.
Delete the file, or replace it with something that matches the new engine:
import { describe, it, expect } from "vitest";
import { GameEngine } from "./engine/GameEngine";
describe("GameEngine", () => {
it("starts with eight classical circles", () => {
const state = new GameEngine().getState();
expect(state.circles).toHaveLength(8);
expect(state.circles.every((c) => !c.isQuantum)).toBe(true);
});
it("removeCircle drops one circle", () => {
const engine = new GameEngine();
engine.getHelpers().removeCircle("circle-0");
expect(engine.getState().circles).toHaveLength(7);
});
it("reset restores every circle", () => {
const engine = new GameEngine();
engine.getHelpers().removeCircle("circle-0");
engine.reset();
expect(engine.getState().circles).toHaveLength(8);
});
});The starter also leaves src/logic/GameLogic.ts and src/rendering/GameRenderer.ts behind. This tutorial draws to a 2D canvas and doesn't use either one. Keep both or delete both, since the renderer imports its state type from the logic module.
Step 4: Create the quantum registry
Create src/logic/QuantumRegistry.ts:
import { QuantumPropertyManager } from "quantum-forge/quantum";
export class QuantumRegistry extends QuantumPropertyManager {
constructor() {
super({ dimension: 2 });
}
/** Put a single circle into an even superposition of gone and exists. */
makeQuantum(id: string): void {
const prop = this.acquireProperty();
const m = this.getModule();
m.cycle(prop); // |0⟩ → |1⟩ (exists)
m.hadamard(prop); // → 50/50 superposition
this.setProperty(id, prop);
}
/**
* Build an entangled pair from two circles.
* The first starts in |1⟩ and the second in |0⟩, so a half i_swap
* mixes them into (|10⟩ + i|01⟩)/√2: exactly one of the two exists.
*/
entanglePair(id1: string, id2: string): void {
const m = this.getModule();
const p1 = this.acquireProperty(); // |0⟩
const p2 = this.acquireProperty(); // |0⟩
m.cycle(p1); // p1 → |1⟩, p2 stays |0⟩
m.i_swap(p1, p2, 0.5); // fraction is required, not optional
this.setProperty(id1, p1);
this.setProperty(id2, p2);
}
/** Probability that this circle exists, read without collapsing anything. */
getProbability(id: string): number {
const prop = this.getProperty(id);
if (!prop) return 1.0;
const results = this.getModule().probabilities([prop]);
for (const r of results) {
if (r.qudit_values[0] === 1) return r.probability;
}
return 0;
}
/** Measure, which collapses the superposition and any partner along with it. */
measure(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; // 1 = exists, 0 = gone
}
}hadamard and cycle build a superposition out of one property. entanglePair deliberately skips the hadamard: two properties that were each hadamard'd first and then i_swapped are correlated in phase but not in outcome, and measuring one would leave the other sitting near 50%. Starting from a definite |1⟩ and a definite |0⟩ is what makes the pair genuinely anti-correlated.
Note that i_swap takes its fraction as a required argument. Gates that do accept an optional fraction behave differently when you omit it: no fraction means the discrete gate, which is not the same operation as fraction 1.0.
Step 5: Wire it up
Replace src/main.ts:
import { ensureLoaded } from "quantum-forge/quantum";
import { GameLoop } from "quantum-forge-engine/rendering";
import { GameEngine } from "./engine/GameEngine";
import { QuantumRegistry } from "./logic/QuantumRegistry";
async function main() {
await ensureLoaded();
// The starter's index.html shows a #loading overlay and hides the canvas
// until the WASM module is ready. Swap them once it is.
const canvas = document.getElementById("game-canvas") as HTMLCanvasElement;
const loading = document.getElementById("loading") as HTMLElement;
loading.style.display = "none";
canvas.style.display = "block";
const ctx = canvas.getContext("2d")!;
const engine = new GameEngine();
const registry = new QuantumRegistry();
const helpers = engine.getHelpers();
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const state = engine.getState();
const clicked = state.circles.find((c) => {
const dx = mx - c.x;
const dy = my - c.y;
return dx * dx + dy * dy < c.radius * c.radius;
});
if (!clicked) {
helpers.select(null);
return;
}
if (!clicked.isQuantum) {
// First click: into superposition.
registry.makeQuantum(clicked.id);
helpers.setQuantum(clicked.id, true, 0.5);
} else {
// Second click: measure.
const exists = registry.measure(clicked.id);
if (exists === 1) {
helpers.setQuantum(clicked.id, false, 1.0);
} else {
helpers.removeCircle(clicked.id);
}
}
});
const loop = new GameLoop({
update: () => {
const state = engine.getState();
for (const circle of state.circles) {
if (circle.isQuantum && registry.hasProperty(circle.id)) {
helpers.updateProbability(circle.id, registry.getProbability(circle.id));
}
}
},
render: () => {
const state = engine.getState();
ctx.fillStyle = "#06080c";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const c of state.circles) {
ctx.globalAlpha = c.isQuantum ? c.existenceProbability * 0.8 + 0.2 : 1.0;
ctx.fillStyle = c.isQuantum ? "#06b6d4" : c.color;
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
if (c.id === state.selected) {
ctx.strokeStyle = "#e8edf4";
ctx.lineWidth = 3;
ctx.stroke();
}
ctx.fillStyle = "#e8edf4";
ctx.font = "14px monospace";
ctx.textAlign = "center";
const label = c.isQuantum
? `${(c.existenceProbability * 100).toFixed(0)}%`
: "click me";
ctx.fillText(label, c.x, c.y + 5);
}
},
targetFps: 60,
});
loop.start();
}
main().catch(console.error);Step 6: Try it
npm run devOpen http://localhost:3000.
- Click a circle. It turns cyan and reads "50%", so it's in superposition.
- Click it again. It either solidifies back to purple or disappears, at roughly even odds.
- Reload and repeat. The outcomes differ every run, because measurement is a real projective measurement against a state vector, not a coin flip in JavaScript.
Step 7: Add entanglement
Shift-click two plain circles to pair them. Replace the click handler from Step 5 with this one:
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const state = engine.getState();
const clicked = state.circles.find((c) => {
const dx = mx - c.x;
const dy = my - c.y;
return dx * dx + dy * dy < c.radius * c.radius;
});
if (!clicked) {
helpers.select(null);
return;
}
if (e.shiftKey) {
// Pairs are built from two plain circles, so ignore ones already quantum.
if (clicked.isQuantum) return;
const selected = state.selected;
if (!selected || selected === clicked.id) {
helpers.select(clicked.id);
return;
}
registry.entanglePair(selected, clicked.id);
helpers.setQuantum(selected, true, 0.5);
helpers.setQuantum(clicked.id, true, 0.5);
helpers.select(null);
return;
}
if (!clicked.isQuantum) {
registry.makeQuantum(clicked.id);
helpers.setQuantum(clicked.id, true, 0.5);
} else {
const exists = registry.measure(clicked.id);
if (exists === 1) {
helpers.setQuantum(clicked.id, false, 1.0);
} else {
helpers.removeCircle(clicked.id);
}
}
});Shift-click one circle to select it (white outline), then shift-click another to pair them. Both read 50%.
Now plain-click either one. It resolves, and on the next frame the partner snaps to the opposite reading: 100% if the measured circle vanished, 0% if it survived. Click the partner and it does exactly what its label promised. One measurement decided both outcomes, which is the whole point of the pair.
What you've learned
QuantumPropertyManagerowns the property lifecycle: acquire, map to an ID, measure, release back to the pool.cycle()thenhadamard()turns a definite state into an even superposition.measure_properties()collapses that superposition and returns a real measured value.probabilities([props])reads the state without collapsing it, and takes an array.i_swap(p1, p2, 0.5)on|1⟩and|0⟩produces an anti-correlated pair, so measuring one fixes the other. Its fraction argument is required.- Applying i_swap to two properties that are already in superposition does not give you that: the construction matters, not just the gate.
releaseProperty()returns handles to the pool, which keeps you under the edition's qudit limit.- Every WASM gate goes through
getModule()and uses snake_case names. - Engine state is read-only. Helpers build new objects and pass them to
setState.