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.