// play first
Climb the gantry. Mind the drones.
Your job is to reach the glowing platform at the top of the derelict gantry. The drones are the point: leave the overlay on (press TAB to toggle) and you'll see the navigation graph drawn over the level — teal links you can walk, gold links are ladders, red are drop-offs — and each drone's current planned route picked out in bright red. Watch one decide to cross a catwalk, climb two ladders, and come for you.
Get up high and the drone that was below you doesn't give up — it finds the nearest ladder and starts climbing. That single behaviour, an enemy that pursues you vertically through the level rather than just left and right, is the whole difference between this and the platformers that taught us what enemies were.
// the contrast
What an enemy was in 1985
Classic platformer enemies were marvels of economy and almost entirely stateless. A Goomba had one rule. A Hammer Bro had a handful. None of them had any representation of the level they lived in — they couldn't, on the hardware — so they never navigated. They executed motion. The challenge came from arranging dumb hazards cleverly, and a generation of brilliant level design grew out of working around exactly that limitation.
// fixed behaviour (1985)
Walk until a wall, then turn. No model of the level; ladders and gaps are invisible to the enemy. It cannot pursue you anywhere it isn't already heading. Difficulty is authored entirely by placement.
// navigation (this)
The level is a graph. The enemy runs A* to plan a real route to your position, then follows it — walking, climbing, dropping. It pursues you through the whole space. The level designer arranges the geometry; the enemy works out the rest.
Two systems make the drones tick. The star is navigation; riding on top is a light adaptive layer that tunes the pressure to how you're doing. Neither is a neural network — it all runs offline in one HTML file, and it's drawn on screen so you can see it work.
- 1 · nav-graphThe level's platforms and ladders, turned into nodes and typed edges (walk / climb / drop).
- 2 · A* pathfinderPlans the cheapest route from the enemy's node to yours, then the enemy follows it move by move.
- 3 · difficulty governorA feedback loop nudging spawn rate and speed to hold you near a challenge target.
// system 1
Turning a level into a graph
An enemy can't reason about a wall of pixels. So the first job is to distil the level into the handful of places where a movement decision happens: the top and bottom of each ladder (where you mount or dismount), and the ends of each platform (where you can drop off). Those become nodes. Then we connect them with edges that record how you'd travel between them — and that "how" is the part the old enemies never had.
// CLIMB edges: a ladder links its bottom node to its top node, both ways top.edges.push({ to: bot.id, move: "climb", cost }); bot.edges.push({ to: top.id, move: "climb", cost }); // WALK edges: adjacent nodes on the same platform, both ways a.edges.push({ to: b.id, move: "walk", cost }); b.edges.push({ to: a.id, move: "walk", cost }); // DROP edges: a platform edge falls onto the nearest platform below — ONE way n.edges.push({ to: landing.id, move: "drop", cost });
The asymmetry matters: climbs go both ways, but a drop is one-way — you can fall down a gap you can't jump back up. Encoding that in the graph is what stops an enemy from planning an impossible route, and it's why the pathfinder produces movement that looks like it understands gravity.
// system 2
A*: planning the route, then walking it
With a graph in hand, pursuit becomes a standard shortest-path problem. The enemy runs A* from its nearest node to the player's nearest node, using a cheap distance estimate as the heuristic, and gets back an ordered list of nodes. Then each frame it looks at the next node, reads the edge type, and does the matching thing: walk toward it, climb toward it, or drop.
const edge = NAV[e.path[e.pathStep]].edges.find(ed => ed.to === tgt.id); e.move = edge ? edge.move : "walk"; if (e.move === "climb") { e.climbing = true; e.vy = 0; e.x += Math.sign(L.x - e.x); // centre on the rungs e.y += Math.sign(dy) * CLIMB_SP; // then climb if (Math.abs(dy) < CLIMB_SP + 1) advance(e); // reached the node? next one } else if (e.move === "drop") { e.vx = Math.sign(dx) * MOVE; stepPhysics(e); // walk off and let gravity work } else { e.vx = Math.sign(dx) * MOVE; stepPhysics(e); // walk along the platform if (Math.abs(dx) < 6) advance(e); }
The enemy re-plans a few times a second, so if you move, it adapts — the bright red path you see in the overlay is this route, recomputed live. A real chase from the ground floor to the top ledge comes out as something like walk → walk → climb → walk → climb → walk → climb: cross to a ladder, go up, cross again, repeat. Nobody scripted that sequence. It fell out of the graph.
// system 3
The governor: pressure that tracks you
Pure navigation would make the drones relentless but the amount of pressure static. So a light governor — the same idea as a thermostat — watches a performance signal and nudges two dials: how often drones spawn, and how fast they move. Clear them easily and the gantry gets busier; struggle and it eases off. It never touches how the drones navigate, only how hard they lean, which keeps the adaptation feeling fair rather than rubber-banded.
function tuneDifficulty() { const target = 0.5; difficulty = clamp(difficulty + (perf - target) * 0.01, 0.6, 1.8); const cap = Math.round(2 + difficulty * 2); // more drones when you're winning if (spawnTimer-- <= 0 && enemies.length < cap) { spawnEnemy(); spawnTimer = Math.round(clamp(180 - difficulty * 60, 70, 180)); } }
// proving it works
Three bugs, three different ways of catching them
A navigating enemy can fail in ways that are invisible to the eye, so the navigation is tested like the load-bearing code it is. The headline test is reachability: from a spawn on the ground, can an enemy actually path to every platform in the level? A single broken graph link is an enemy stuck forever, and you'd never reliably spot it by playing. nav.test.js asserts the whole level is reachable; harness.js then loads the real game with no browser and runs thousands of frames, confirming enemies plan routes, climb to hunt a player above them, and that difficulty stays in bounds.
What's honest to report is that the three real bugs this build threw up were each caught a different way — and that's the actual lesson about testing games.
- the graph bugDrop-landing nodes were created after the walk-links were wired, so any enemy that dropped off a ledge landed somewhere with no way to walk on. Invisible in play. The reachability test caught it automatically.
- the feel bugsLadders: you couldn't step off sideways at the top, and stacked ladders wouldn't let you climb through the platform between them. No test would think to check these — they surfaced by playing, then got pinned down with targeted tests so they'd never come back.
- the physics bugPoint-blank shots passed straight through a touching enemy: the bullet spawned ahead of the muzzle and out-ran the target in one frame. Found by play, fixed with swept collision, locked down with a test.
ok every ladder connects two real platforms
ok no node has a dangling edge (all targets exist)
ok an enemy on the ground can reach EVERY platform
ok enemy can path from ground to the top goal ledge
ok that route actually uses ladders (climbs > 1)
chase route: walk -> walk -> climb -> walk -> walk -> climb -> walk -> walk -> climb
... 9 passed, 0 failed
A headless harness is brilliant at the things it can measure — reachability, bounds, "does an enemy ever climb" — and useless at the things only a human notices, like a ladder that feels wrong to dismount. The win isn't "tests instead of playing." It's tests for the invisible structural failures, playtesting for feel, and a targeted regression test written the moment play turns up something, so it stays fixed.
// make it yours
Where to take it
The navigation is the reusable part. Because the graph is built from a plain list of platforms and ladders, you can hand it a different level and the enemies just navigate it — no AI changes. Natural extensions: add a jump edge type for gaps an enemy can leap, and the pathfinder folds it into routes for free; give drones a flow-field instead of individual A* if you want dozens of them cheaply; add line-of-sight so they only chase what they can see, and have to search when they lose you; or let the governor read a second signal, like how long you linger, to vary where they spawn rather than just how many.
The deeper version, for another day: the nav-graph is exactly the substrate a learning agent needs. Reward an enemy for cutting off your route to the exit and you'd have drones that learn to herd rather than chase — the same step from the shoot-'em-up's bandit toward something that reasons about the level over time. The graph is already the hard part; the learning sits on top of it.