v…
Data-Oriented Design, by picture · Chapter 2

Bigger Maps & Pathing

A 4×4 world and tanks that route themselves across it — every path table live on the page.

loading…
Tap a tank to cycle it: auto-path (tap a cell to send it there) → manual (drive it: W/S/A/D or the pad) → unselected. A selected tank's screen is followed; its route is drawn in its own colour. Tap screen x,y ▾ to look around — with a tank selected you can pick another screen and tap a cell there to send it across the world.

This is chapter two of an interactive book about data-oriented design. Chapter 1 was two tanks on a single grid of walls. Chapter 2 grows the world to a 4×4 arrangement of screen-grids and adds pathing: a tank you give a destination, and it routes itself there along the shortest wall-free way, across screens and around the world's wrap. As before, every data structure below is live — it reads and pokes the running game's memory directly.

The whole chapter is one idea applied twice: the path is a table, and a moving tank just does a lookup. Working out the shortest route is the expensive part, so we do it once against the rarely-changing walls and store the answer; per tick a tank reads the next step. The art is making the stored answer small.

The world is one big toroidal grid, organised as screens

Mechanically there is a single (4·20)×(4·15) = 80×60 toroidal grid. The 4×4 split into 20×15 screens is an organisation — for display (the viewport shows one screen), for scrolling, and for the two-level pathing below. A tank crosses from one screen into its neighbour wherever the border cell and the neighbouring cell are both open — exactly chapter 1's "depends only on the wall on the far side" rule, now applied at every inner screen edge and at the outer wrap. Each screen border has 0, 1, or 2 matching openings — not every border connects, but a randomised spanning tree forces enough that no screen is an island, and the generator fails the build if one is. Each of the 16 screens also has a distinct interior — blocks, pillars, rooms, dead-ends, a maze, an enclosed island of unreachable cells (route a tank into one — no path) — so they are easy to tell apart and each gives the flow field below something different to solve. Tap the screen badge to open the picker and look around:

the 4×4 world (walls, tanks, and live routes) · the outlined screen is the viewport · click a screen to view it

The viewport is presentation only: which screen you look at never changes the simulation. A tank keeps pathing whether or not its screen is in view — open the picker and look elsewhere and it carries on.

One kind of tank, three states

All the tanks are identical. Each carries a small state you cycle by tapping it: auto-path (tap a cell and it routes there), manual (you drive it — the on-screen pad and W/S/A/D appear in this state), or unselected (it keeps following any route it has). At most one tank is selected at a time, and its screen is followed: when it drives across a border, the new screen slides in. A tank's one input byte comes from one of two sources — the controls or the path lookup — and both feed the same turn/move transforms. The state picks the source; the data and the movement are the same.

per-tank data · state, destination, status, and the current direction lookup · tap “cycle state” or the tank itself

Level 1 — within a screen: all-pairs, stored as distance

Inside one screen you route any cell to any cell — the all-pairs table the grid-paths project builds. 300 cells make ~90,000 ordered pairs; the table below packs that into 45 KB.

The key move: store the metric, derive the arrow. Distance is symmetric (dist(a,b) == dist(b,a)), so we keep one byte per unordered pair — the upper triangle. Direction is asymmetric, so we derive it at runtime: the next step from a cell toward a target is the neighbour whose stored distance to the target is one less. One table serves both the per-tick arrow and the Level-2 weights below. Pick a target cell and watch its flow field — the arrow each cell takes to reach it, derived live from the distances:

a screen's flow field · choose a screen and click a target cell · arrows are derived from the stored distances

How big is one entry? Each distance is built from the grid, so it is small and bounded, and the bound is provable. A shortest path inside a screen never revisits a cell, and no 2×2 block can sit entirely on one (four cells would close a loop), so at most three of every four cells lie on it: the longest in-screen distance is at most 300 − 10·7 − 1 = 229. That fits one byte (255 is reserved for "unreachable"), guaranteed at compile time by a _Static_assert. The build also measures the actual longest distance — shown in the counters above — so a screen that ever exceeded the byte would be caught.

So one entry is one byte: 45,150 pairs = 45 KB per screen, 706 KB for all 16. The table is filled by a breadth-first search on init, rebuilt for one screen when you edit a wall there, and read by a lookup every tick. Toggle walls in the current screen and watch the flow field re-solve:

the current screen's wall bitset · click a cell to toggle it (rebuilds that screen's Level-1 table + the edge graph)

Level 2 — between screens: a next-hop matrix over edge points

The connecting edge points are the open border cells whose neighbour across the border is also open — the matching gaps. Over just those points we build a second all-pairs table: for a start point A and an end point B, the next edge point on the shortest route (and its length). The win is that this is built on top of Level 1: two edge points on the same screen are joined by an edge weighted with that screen's Level-1 distance; crossing a matched border costs 1. Level 2 reuses the distances Level 1 already stored, and a small Floyd–Warshall fills the matrix. With the default map there are only ~80 edge points, so the matrix is tiny.

the connecting edge points (matched gaps) and the next-hop matrix · pick a source edge point to see its row

Composing the two levels into a route

When you click a destination, we fold the matrix into a tiny per-tank vector: for every edge point, the shortest remaining distance to the goal (Level-2 distance to a goal-screen entry point, plus that screen's Level-1 distance in to the goal cell). That fold happens once, when the destination changes — not per tick. After that, each tick a routing tank in screen S either follows S's Level-1 flow straight to the goal (if it's there and reachable) or picks the exit edge point that minimises distance-to-the-exit + 1 + remaining-from-its-partner — a handful of table reads. Because every step strictly decreases the true remaining distance, the route is the globally shortest one and never enters a wall; an unreachable destination produces no path at all, shown as such. Each routing tank's path is drawn in its own colour, read from the same tables it follows; where two paths share a cell the marker splits into coloured strips so both stay visible.

Tune the pace and drive a tank (tap it twice to make it manual) into things to feel the inherited chapter-1 movement (slide along walls, auto-steer out of corners) — the auto-pathing tanks ride on top of the very same model:

tunables · per-tick amounts (collide_scale is out of 256)

What this bought us

Static memory, integer math, flat tables: a 4×4 world with self-routing tanks in a few kilobytes of WebAssembly plus under a megabyte of packed integer tables, all sized to their real domain and printable on this page. Each table has a stated job — how often it is built, how often it is read, and why it is shaped the way it is. The simulation is separated from rendering and wasm, so the whole thing, pathing included, is tested natively with no browser or GPU.

Source for this chapter: sim.c · grid_paths.c · edge_paths.c · tanks_path.c · render.c · wasm.c · test.c · README · CONTRACT