tutorial

Build a bridge game

Drag beams between joints, press Run, and watch a vehicle cross while every beam turns from green toward red as it takes load. Everything below is quoted from examples/bridge, a working game of about 500 lines: a renderer-free simulation in sim.ts, a Pixi scene in main.ts, and a test that proves the replay of a build gives the same result to the last decimal.

Run it with npm run example:bridge in a clone of the repository, or build it once with npm run example:bridge:build and open examples/bridge/dist/index.html by double-clicking it: no server. This tutorial assumes you have been through Getting started.

01 Why a truss

XpbdWorld (@datamoc/mwg/physics/xpbd) is a deterministic structure solver: nodes are point masses, links are springs, and after every step each link reports the load it carries. The first thing it teaches is a lesson about bridges. A chain of planks laid end to end has no vertical stiffness at all: under a vehicle it only sags, like a rope. What gives a deck its stiffness is triangles, so the game's answer key is a Warren truss: a chord along the deck, a chord above, and diagonals that zigzag between them.

export function warrenBeams(level: Level): Beam[] {
	const spans = level.deck.length - 1;
	const top = (i: number): number => level.deck.length + i;
	const beams: Beam[] = [];
	for (let i = 0; i < spans; i++) beams.push([i, i + 1], [i, top(i)], [top(i), i + 1]);
	for (let i = 0; i < spans - 1; i++) beams.push([top(i), top(i + 1)]);
	return beams;
}

Joints 0 to spans are the deck, the joints after them are the row above, and top(i) sits halfway along bay i. The level hands the player these joints and nothing else; finding the triangles is the game.

02 The world and the nodes

The simulation never sees a pixel. A joint becomes a node, a beam becomes a link, and buildBridge is the whole translation. The two banks are fixed nodes that never move; every other joint is free to sag.

export function buildBridge(level: Level, beams: readonly Beam[]): Bridge {
	const world = new XpbdWorld({ gravity: GRAVITY });
	for (const joint of level.joints) world.addNode({ x: joint.x, y: joint.y, fixed: joint.fixed });
	const links = beams.map(([a, b, cable]) => world.addLink({ a, b, tensionOnly: cable === true, ...BEAM }));
	return { world, links };
}

stiffness is force per unit of stretch, so a higher number is a more rigid beam; strength is the load at which the link breaks. Because the steps are fixed and nothing is random, the same beams always give the same bridge on every machine, which step 08 relies on.

03 Levels as data, and a budget

A level is a plain object: joints, which of them the vehicle drives over, a budget, a price per metre of beam, the vehicle's mass, and how far the deck may drop before the vehicle falls through. Nothing in it is code, so a level could as well be a JSON file.

export interface Level {
	name: string;
	joints: Joint[];
	/** the joints the vehicle drives over, left to right */
	deck: number[];
	budget: number;
	costPerMetre: number;
	/** the vehicle's mass; its weight is mass times gravity, spread over the deck under it */
	vehicleMass: number;
	/** how far the deck may drop under the vehicle before the vehicle falls through */
	maxSag: number;
}

The three levels are a short span, a wide gorge, and a gap with a pier in the middle:

export const LEVELS: Level[] = [
	gapLevel('Short span', 6, 1200, 2),
	gapLevel('Wide gorge', 8, 1600, 2.5),
	gapLevel('The pier', 10, 1850, 8, [5]),
];

The budget is what turns a puzzle with one answer into a design problem. A beam costs its length in metres times the price per metre, and a placement is refused when it would break the budget, run too long, repeat a beam or join nothing:

export function beamCost(level: Level, beam: Beam): number {
	const price = (beamLength(level, beam) / UNITS_PER_METRE) * level.costPerMetre;
	return Math.round(beam[2] ? price / 2 : price);
}
export function beamProblem(level: Level, beams: readonly Beam[], beam: Beam): string | null {
	const [a, b] = beam;
	if (a === b || !level.joints[a] || !level.joints[b]) return 'a beam joins two different joints';
	if (beams.some(([c, d]) => (c === a && d === b) || (c === b && d === a))) return 'that beam is already there';
	if (beamLength(level, beam) > LONGEST_BEAM) return 'too long for one beam';
	if (totalCost(level, beams) + beamCost(level, beam) > level.budget) return 'over budget';
	return null;
}

04 Building with the pointer

Input.attachPointer turns a canvas into down, move and drag signals. Its toWorld hook maps CSS pixels into the game's own units, here by undoing the stage's scale and offset, so everything after it is in bridge coordinates. Pressing on a joint starts a beam, releasing on another one places it:

const toWorld = (x: number, y: number): { x: number; y: number } => ({
	x: (x - this.stage.x) / this.stage.scale.x - this.world.x,
	y: (y - this.stage.y) / this.stage.scale.y - this.world.y,
});
const pointer = Input.attachPointer(Game.current.app.canvas, { toWorld });
pointer.down.add((p) => {
	this.from = this.jointAt(p.worldX, p.worldY);
});
pointer.move.add((p) => {
	this.cursor = { x: p.worldX, y: p.worldY };
});
pointer.up.add((p) => this.drop(this.jointAt(p.worldX, p.worldY)));
private drop(to: number | null): void {
	const from = this.from;
	this.from = null;
	if (from === null || to === null || from === to) return;
	const problem = beamProblem(this.level, this.beams, [from, to]);
	if (problem) this.say(`Cannot place that beam: ${problem}`);
	else this.dispatch(`${this.cables ? 'cable' : 'beam'}:${from}:${to}`);
}

drop does not change the bridge itself. It asks sim.ts whether the beam is allowed, then dispatches an action string. The next step shows why every change goes through a string.

05 Running a crossing, and reading ratio

A Crossing drives a vehicle over the deck one frame at a time. The vehicle is a downward force on the two deck joints under it, shared by how far along the segment it is; world.step() then moves everything and updates each link's ratio, the smoothed load as a fraction of its strength. It breaks above 1.

step(): void {
	const world = this.bridge.world;
	this.frames++;
	world.breaking = this.frames > SETTLE_STEPS;
	if (world.breaking) {
		const { index, t } = this.where();
		const weight = this.level.vehicleMass * GRAVITY;
		world.applyForce(this.level.deck[index], 0, weight * (1 - t));
		world.applyForce(this.level.deck[index + 1], 0, weight * t);
		if (!this.fell) this.distance += VEHICLE_SPEED / 60;
	}
	world.step();
	this.peakLoad = Math.max(this.peakLoad, world.peakRatio);
	this.level.deck.forEach((joint, i) => {
		if (!this.fell) this.sag = Math.max(this.sag, world.nodes[joint].y - this.restY[i]);
	});
	if (world.breaking && this.sag > this.level.maxSag) this.fell = true;
	if (this.fell) this.collapse++;
}

ratio is the number a strain gauge wants, so the game paints each beam with it: green when idle, yellow past half, red at the breaking point.

export function ratioColour(ratio: number): number {
	const t = Math.min(1, Math.max(0, ratio));
	const red = Math.round(80 + 175 * Math.min(1, t * 2));
	const green = Math.round(200 - 140 * Math.max(0, t * 2 - 1));
	return (red << 16) | (green << 8) | 70;
}
this.crossing.beams.forEach(([a, b, cable], i) => {
	const link = world.links[links[i]];
	if (link.broken) return;
	const stroke = { color: ratioColour(link.ratio), width: cable ? 2.5 : 5 };
	gfx.moveTo(world.nodes[a].x, world.nodes[a].y).lineTo(world.nodes[b].x, world.nodes[b].y).stroke(stroke);
});

The scene advances the crossing in fixed 1/60 s steps whatever the display's frame rate, so the picture you watch is the same computation the tests run through cross().

06 Cables: tensionOnly

A link with tensionOnly: true is a cable. It pulls when stretched and goes slack, without pushing, when compressed. In the game the player's pen has two modes (the Beams button toggles it), and a cable costs half as much:

return Math.round(beam[2] ? price / 2 : price);

The beam tuple carries the flag, and buildBridge from step 02 turns it into tensionOnly. The lesson is in where a cable can go. The deck of a sagging bridge is in tension, so a cable there carries the same vehicle for less money. The top chord is in compression, so a cable there just goes slack and the bridge sags. The test file checks both.

07 Breaking

Links break only while world.breaking is true. The crossing leaves it off for the first 60 frames so the bridge can settle under its own weight, then turns it on as the vehicle starts (look at the top of step above). When a link passes its strength it stops carrying load, stays in world.links with broken: true, and fires onBreak once with where it went:

this.crossing.bridge.world.onBreak.add((spot) => {
	this.breaks.push(spot);
});

The scene draws a red ring at each break and skips broken links. A bridge that sags past the level's limit keeps simulating for another 90 frames, so you can see the collapse and which beams gave way instead of the picture freezing at the moment of failure.

08 A deterministic replay

Everything the player does is one of four strings: beam:a:b, cable:a:b, clear and run. That is what makes the game replayable. A core.Recorder listens to a signal of actions and stamps each with the current tick; here the tick is one per action, so a replay runs briskly instead of waiting through every pause the player took:

private readonly actions = new Signal<string>();
private readonly ticks = new Signal<number>();
private recorder = new Recorder(this.actions, this.ticks);
private dispatch(action: string): void {
	if (this.running || this.player) return;
	this.actions.dispatch(action);
	this.ticks.dispatch(this.recorder.frame);
}

To replay, serialise the recording, clear the bridge and let a Player re-dispatch the events on its own clock into the same perform the live game uses. Each time a replayed crossing finishes, the scene compares its result with the one the live run produced:

private replay(): void {
	if (this.running || this.runs.length === 0) return;
	const saved = serializeReplay(this.recorder.toJSON());
	this.beams = [];
	this.perform('clear');
	this.replayRun = 0;
	this.player = new Player(deserializeReplay(saved), (action) => this.perform(action), this.replayClock);
}
if (this.player && !this.running && (this.replayAt += dt) >= 0.3) {
	this.replayAt = 0;
	this.replayClock.dispatch(0);
	if (this.player.done && !this.running) this.player = null;
}

The Replay button shows the line Replay: the result is identical, down to the last decimal. It is not a trick: the solver is deterministic, so the same actions give the same bridge, the same sag and the same peak load. tests/bridge-example.test.ts proves it with a plain deepEqual on the two results.

09 Saving the best result

SaveSystem keeps named, versioned slots over localStorage (an in-memory store where there is none, which is also what a test gets). The game keeps one slot per level, holding the cheapest crossing so far:

const saves = new SaveSystem<{ cost: number; sag: number }>({ namespace: 'bridge', version: 1 });
this.runs.push(result);
const best = saves.load(`best-${this.levelIndex}`)?.state;
if (isBetter(result, best)) saves.save(`best-${this.levelIndex}`, result);
this.say(describe(this.level, result));

The state is plain JSON. load returns null for a missing or damaged slot, so the HUD can ask for the best result without a try block. Under file:// Chromium shares one localStorage between local pages; the bridge namespace keeps this game's slots its own.

10 Reduced motion and the theme

The only decorative motion in the game is the result line fading in. motionDuration answers how long a motion should take given the player's operating-system preference (or the game's own setting): it returns the duration you asked for, or 0 when reduced motion is on, in which case the text simply appears. Motion that carries meaning, like the vehicle crossing, is the game itself and is not touched.

//decorative: the message fades in; with reduced motion it simply appears
this.message.age += dt;
const fade = motionDuration(0.4);
this.labels.message.alpha = fade === 0 ? 1 : Math.min(1, this.message.age / fade);

Colours come from the interface theme rather than being hard-coded, so one setTheme call restyles the HUD. The labels read the theme each frame, and the Contrast button swaps in the built-in high-contrast theme:

private paintLabels(): void {
	const { color } = theme();
	const best = saves.load(`best-${this.levelIndex}`)?.state;
	const spent = totalCost(this.level, this.beams);
	this.labels.title.setText(`Level ${this.levelIndex + 1}: ${this.level.name}`);
	this.labels.title.setColor(color.textHighlight);
	this.labels.budget.setText(`Budget ${spent} / ${this.level.budget}  (${this.beams.length} beams)`);
	this.labels.budget.setColor(color.text);
	this.labels.best.setText(best ? `Best: cost ${best.cost}, sag ${best.sag}` : 'Best: none yet');
	this.labels.best.setColor(color.textDim);
	this.labels.message.setText(this.message.text || ' ');
	this.labels.message.setColor(this.failed ? 0xff6a5a : color.textHighlight);
}
['Contrast', () => setTheme(theme() === highContrastTheme ? {} : highContrastTheme)],

Colour alone does not carry the result: a failed crossing says so in words, and a break is marked with a red ring, not only a red beam.

11 The interface as content

The HUD here is a handful of labels and buttons built in code, which is the right size for a small game. A larger game usually wants its windows, lists and message boxes described as data instead, so a designer can change them without touching TypeScript. examples/mwl-presentation does exactly that: a HUD, buttons, windows and themes written as MWL content and read by the same framework. Start there when your screens outgrow a page of constructor calls.

Build everything for file:// the same way as in Getting started, step 10: npm run example:bridge:build runs the example's Vite build and tools/emit-page.mjs, and the result opens by double-clicking dist/index.html.

Ideas for the next change: a fourth level with a river that rises, a second vehicle, a price per beam material (stronger and dearer), or a leaderboard of replay files that other players can load and watch.