Skip to content

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.

It uses the handle API from quantum-forge 3.0. If your project is on 2.x, see Migrating to 3.0.

The tutorial builds on the optional quantum-forge-engine package for its scaffold, state class and renderer. The quantum calls are the same without it. For a game on the core package alone, read the source of Quantum Pong: npx quantum-forge example my-pong.

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 ​

bash
npx -y quantum-forge-engine init quantum-circles \
  --template starter --platforms web --edition qutrit --no-claude-skill
cd quantum-circles

The scaffolder 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:

typescript
import { Engine } from "quantum-forge-engine/engine";
import type { Quantum } from "quantum-forge/quantum";

export interface Circle {
  id: string;
  x: number;
  y: number;
  radius: number;
  color: string;
  /** Set while the circle is quantum. A circle without one is plain and exists. */
  exists?: Quantum<boolean>;
}

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",
    });
  }
  return circles;
}

export class GameEngine extends Engine<GameState> {
  constructor() {
    super({ circles: createCircles(), selected: null });
  }

  reset(): void {
    // The old circles are gone, so end the life of any quantum state they held.
    for (const c of this.getState().circles) c.exists?.dispose();
    this.setState({ circles: createCircles(), selected: null });
  }

  getHelpers() {
    return {
      setExists: (id: string, exists: Quantum<boolean> | undefined) => {
        const state = this.getState();
        this.setState({
          ...state,
          circles: state.circles.map((c) => (c.id === id ? { ...c, exists } : 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 });
      },
    };
  }
}

The quantum state lives on the circle it describes. exists is a handle from quantum([false, true]), and a circle without one is plain. There is no id map to keep in sync: when a circle leaves the state, its handle goes with it.

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 setExists maps over the array instead of finding a circle and editing it. A spread copy of a circle still holds the same handle, so copying state never copies quantum state.

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:

typescript
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.exists)).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);
  });
});

These tests never create a quantum handle, so they run without loading the WASM module.

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: Write the quantum logic ​

Create src/logic/CircleQuantum.ts:

typescript
import { quantum, type Quantum } from "quantum-forge/quantum";

/** A circle that may or may not exist: an even superposition of false and true. */
export function maybeExists(): Quantum<boolean> {
  return quantum([false, true]).superpose();
}

/**
 * Two circles of which exactly one exists. The first starts true and the
 * second false, and a half iSwap leaves them as (|10⟩ + i|01⟩)/√2.
 */
export function exactlyOne(): [Quantum<boolean>, Quantum<boolean>] {
  const first = quantum([false, true]).flip(); // true
  const second = quantum([false, true]);       // false
  first.iSwap(second, 0.5);                    // the fraction is required
  return [first, second];
}

/** Chance that a circle exists. Reading it never collapses anything. */
export function existenceProbability(exists: Quantum<boolean> | undefined): number {
  return exists ? exists.probability(true) : 1;
}

/** Look at a circle: collapse it, and its partner with it, then end the handle's life. */
export function observe(exists: Quantum<boolean>): boolean {
  const value = exists.measure();
  exists.dispose();
  return value;
}

quantum([false, true]) declares a property by the values it can take. It starts at the first one, false. superpose() is the game-word name for hadamard() and runs the same gate: on a fresh property it gives true and false a probability of 1/2 each.

exactlyOne builds its pair from definite values on purpose. The two properties must start different: iSwap on two equal values does nothing. One true and one false is what makes the pair anti-correlated, so measuring one settles the other.

iSwap takes its fraction as a required argument. The single-property gates take an optional one, and leaving it out runs the discrete gate.

observe measures, then calls dispose(). Disposing frees the qudit so the game stays under the edition's qudit limit however many circles it creates. A measured handle would still work if you kept it, but a circle that has been looked at is plain again, so this game lets it go.

Step 5: Wire it up ​

Replace src/main.ts:

typescript
import { ensureLoaded } from "quantum-forge/quantum";
import { GameLoop } from "quantum-forge-engine/rendering";
import { GameEngine } from "./engine/GameEngine";
import { maybeExists, observe, existenceProbability } from "./logic/CircleQuantum";

async function main() {
  await ensureLoaded(); // once, before the first quantum() call

  // 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 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.exists) {
      // First click: into superposition.
      helpers.setExists(clicked.id, maybeExists());
    } else if (observe(clicked.exists)) {
      // Second click: measured, and it exists. It is a plain circle again.
      helpers.setExists(clicked.id, undefined);
    } else {
      // Measured, and it does not exist.
      helpers.removeCircle(clicked.id);
    }
  });

  const loop = new GameLoop({
    update: () => {},
    render: () => {
      const state = engine.getState();
      ctx.fillStyle = "#06080c";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      for (const c of state.circles) {
        const p = existenceProbability(c.exists);
        ctx.globalAlpha = c.exists ? p * 0.8 + 0.2 : 1.0;
        ctx.fillStyle = c.exists ? "#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.exists ? `${(p * 100).toFixed(0)}%` : "click me";
        ctx.fillText(label, c.x, c.y + 5);
      }
    },
    targetFps: 60,
  });

  loop.start();
}

main().catch(console.error);

The renderer reads each circle's probability every frame. Reading a probability never collapses anything, so this is safe in a render loop and there is nothing to cache.

Step 6: Try it ​

bash
npm run dev

Open http://localhost:3000.

  1. Click a circle. It turns cyan and reads "50%", so it's in superposition.
  2. Click it again. It either turns back to purple or disappears, at even odds.
  3. 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. Add exactlyOne to the import from ./logic/CircleQuantum, then replace the click handler from Step 5 with this one:

typescript
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.exists) return;

    const selected = state.selected;
    if (!selected || selected === clicked.id) {
      helpers.select(clicked.id);
      return;
    }

    const [first, second] = exactlyOne();
    helpers.setExists(selected, first);
    helpers.setExists(clicked.id, second);
    helpers.select(null);
    return;
  }

  if (!clicked.exists) {
    helpers.setExists(clicked.id, maybeExists());
  } else if (observe(clicked.exists)) {
    helpers.setExists(clicked.id, undefined);
  } 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.

Nothing in the click handler mentions the partner. Measuring one property of an entangled pair collapses the other in the same step, wherever it is stored. Disposing the measured handle afterwards leaves the partner alone with its settled value.

What you've learned ​

  • quantum([false, true]) declares a quantum property by its values. It starts at the first one.
  • Store the handle on the thing it describes. Here that is a field on the circle, and a circle without one is plain.
  • superpose() (the same gate as hadamard()) puts a fresh property into an even superposition.
  • probability(true) reads the state without collapsing it, so the renderer can call it every frame.
  • measure() collapses the property and returns a declared value, true or false here.
  • a.iSwap(b, 0.5) on one true and one false makes an anti-correlated pair, so measuring one settles the other. Its fraction is required.
  • dispose() ends a handle's life and frees its qudit. Call it when the thing it describes is gone. The engine's EntityManager does this for you when it removes an entity; see Lifecycle.
  • Engine state is read-only. Helpers build new objects and pass them to setState.

Powered by Quantum Forge