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

Tank Combat

A tiny game built data-first — in C, WebAssembly, and WebGPU.

loading…
Tank 0 (amber): W/S move · A/D turn    Tank 1 (cyan): ↑/↓ · ←/→  — or use the on-screen pads.

This is chapter one of an interactive book about data-oriented design: the practice of understanding the real data first, then writing the simplest machine that turns the input you have into the output you need. Rather than explain it abstractly, we build a small game and look at the actual bytes. Every readout and control below is live — it reads and pokes the running game's memory directly.

The whole game is two tanks driving around a grid of walls. Drive a tank into a wall and hold the throttle: it steers itself along the wall and out of corners. Everything you see is computed from a few hundred bytes of integer data.

The whole game is data

There are no game "objects." The entire world is one flat struct — a handful of arrays for the two tanks (position, heading, input, velocity), the wall grid, and two tunable numbers. There is no dynamic allocation at all: every byte lives in fixed, static memory, decided at compile time.

The simulation runs on a fixed timestep: one tick is one frame, and each tick is two small transforms run in order — turn (which also auto-steers out of collisions), then move. Because the step is fixed, there is no delta-time to multiply through the math; the per-tick amounts are the data. Watch the frame counter, and step one tick at a time:

live world counters · pause then step to advance one tick

The grid is bits in integers

A wall cell is one bit of information: wall or empty. So the grid isn't a 2-D array of bytes — it's a bitset: one uint32_t per row, with bit c meaning "column c is a wall." The whole 20×15 map is 15 integers, 60 bytes.

This isn't just smaller; it changes the work. To check whether a tank overlaps a wall, the collision code builds a bit-mask of the few columns the tank covers and ANDs it against each row word — one operation tests a whole span of cells instead of a loop. Click any cell to flip its bit and watch the game react (collision and the auto-steering update immediately):

the wall bitset · click a cell to toggle its bit

The starting map is stored directly as the 15 row-words (with an ASCII picture in a comment for humans) — there's no level "parser," because the values are already in the form the program uses.

Numbers are integers — fixed point, no floats

There is no floating point anywhere in the simulation. Positions are in subcells: 256 subcells make one grid cell, stored as a 16-bit integer (a Q8.8 fixed-point number). A tank's heading is a 16-bit angle where the top 5 bits pick one of 32 directions and the low 11 bits are sub-direction precision, so turning can be finer than one direction per tick.

The editors below show and let you set each tank's raw numbers. The live line shows the actual stored integers: position in subcells, the direction index, the input bits, the velocity applied last tick, and the collision flag (which axis was blocked).

per-tank data · edit a field to write it straight into memory

Don't store what you can derive

Movement needs sine and cosine of the heading, but we never call a math library — those are precomputed into a small table of 32 values, in Q14 fixed point (×16384). And we keep only one table: sine is just cosine a quarter-turn earlier, so the sine of a direction is read from the cosine table at an offset. One table, no redundant data to keep in sync.

Transforms over batches, not objects

The tanks are stored as a "structure of arrays" — all the headings together, all the inputs together — so each transform streams just the one field it needs. There is one deliberate exception: a tank's x and y are never used apart (collision, the pattern lookup, and rendering all read the pair), so they are packed together in a single word and fetched in one load — splitting them would only add a second possible cache miss for no benefit. Heading stays in its own array, because a different transform writes it. (That's the rule: structure-of-arrays by default; merge only what is always used together.) The transforms are written for a batch; two tanks is simply a batch of two. Each tick there are two: turn rotates the heading, and move steps each tank along it, resolving collision one axis at a time so a tank slides along a wall it meets at an angle. The collision test is tiny: since a tank moves less than one cell per tick from a cell that was already clear, only its leading edge can hit anything new, so it checks just the one cell the edge is entering — exploiting the data to do less work.

Rotation is one transform because it is all the same thing — writing the heading. It does the player's left/right input, and the auto-steer below. The interesting one is the auto-steer.

A table is also a way to see the data. We first wrote the input decode as a 16-row table — one row per combination of the four buttons — listing the turn, throttle, and reverse flag for each. Laid out like that, the pattern was obvious: every column is just a simple function of the bits (turn is right-minus-left, and so on), so the table wasn't needed at all — a few branch-free operations compute it. Tabulating first showed us which values we actually need; then the data told us we could drop the table.

The escape, though, genuinely earns a table. "I want to go this way but I'm stuck — which way should I turn?" Even the search for the nearest open direction is precomputed for every facing, because it depends only on the walls around the tank. The question is: which walls?

The response is a lookup into 16 local patterns

Turn on show sampled cells in the controls above and drive into a wall. Two colors light up the cells the code actually reads this tick: the leading-edge cell(s) the move tests, and the cells the steer reads to decide which way to turn out.

The striking thing is how few there are. The steer only ever reads the four orthogonal neighbours of the tank's cell — never the diagonals. So the entire "which way can I go?" answer for a cell is a function of just four bits (is there a wall north / east / south / west). Four bits means only 16 possible local situations in the whole game. We verified this by grouping every cell of every map by its 4-neighbour pattern: cells that share a pattern always produce the identical set of open directions.

That collapses the table dramatically. What could be "an escape direction for every cell and every facing" (300 cells × 32 facings ≈ 9.6 KB) is really only 16 patterns × 32 facings = 512 bytes — and because a pattern is a pattern regardless of where it sits, the table depends on nothing but the fixed geometry. So it isn't computed at runtime at all: a small host program generates it and it is baked into the binary as data (like the trig table) — the game builds nothing on startup. Each tick the steer reads the tank cell's four neighbours (four bit-tests), forms the 4-bit pattern, and does one lookup. The big realization of data-oriented design in miniature: we thought the input was "the whole grid," but the response only ever depended on a tiny local pattern — so the real input is tiny, and the fully-determined table belongs in the build, not the runtime.

The movement contract

Holding forward (or backward) makes a promise: the tank keeps making progress whenever any escape exists. If it collided, the turn transform's auto-steer rotates it toward the nearest direction it can actually move — so it slides along flat walls, turns out of corners (including the arena's own), and rotates around to the mouth of a dead-end pocket. It never stays permanently stuck unless it is truly sealed in. Auto-steer only acts when you are not turning: hold left or right and the heading is yours, so you can steer freely — even straight into a wall.

This promise is written down in CONTRACT.md and checked by a suite of native tests (more below). A tank that is still in contact with a wall (it was blocked last tick) moves at a fraction of its speed — collide_scale out of 256, 128 = half — so it eases against walls rather than grinding at full speed, then picks back up once it slides clear. Crank the speed, turn rate, and collision scale and drive into walls to feel it:

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

Two details make this robust. The escape turn always rotates the same way (the direction the search found the opening), never the "shortest" way — otherwise a 180° escape would flip sign every tick at the exact opposite angle and jitter in place. And the search asks whether a whole cell over is open, not whether a sub-cell nudge is possible, so it can't be fooled by a dead-end that only admits a wiggle.

The map wraps around

There is no hard edge: the arena is a torus. Drive off one side and you reappear on the other — and whether you can cross depends only on the wall on the far side. The default map already has a one-cell gap at the centre of each edge, matched across opposite edges, so a tank driving out through one reappears from the opposite one; if the far side has a wall instead, you collide with that wall. (Toggle border cells in the grid above to open or close your own crossings.) In the data this is just arithmetic: positions are taken modulo the arena size, and the collision check reads the grid at wrapped coordinates.

An explicit data protocol to the screen

The simulation never talks to the GPU. Instead the world becomes a flat instance buffer: one 16-byte record per quad (wall or tank part) — center, half-size, and rotation as integers, plus a color packed as a conventional uint32_t RGBA. The vertex shader is the only place an integer becomes a float, because that's the GPU's native data format. C owns every byte of the layout; JS only asks for pointers.

But almost none of that buffer changes from frame to frame. About 93% of the quads are walls, which only change when you edit the grid; just the handful of tank quads move each tick. Rewriting and re-uploading the walls every frame would be a ghost write — work whose output is identical to last frame's. So the buffer is split by how often each part changes: a small fixed region at the front holds the dynamic quads (tanks and the debug overlay) and is the only thing rebuilt and re-uploaded each tick; the walls sit behind it, written once and re-uploaded only when a version counter says the grid changed. Same single instanced draw, but the per-frame work dropped from ~111 quads to ~20. This is the same "match the work to the frequency of change" rule that baked the escape table — here applied to the pixels.

Simulation separated from rendering

Because the simulation depends on nothing but its own data — not the GPU, not WebAssembly — it also builds and runs as a plain native program. That's how the movement contract is tested: a small C test sets up each case (a flat wall, a corner, a U-shaped pocket driven into forward and in reverse, a long roam of the real map) and asserts the tank escapes, with no browser or GPU involved. Rendering and the wasm boundary sit on top; the dependency only ever points one way.

What this bought us

No allocation, no floats, no objects — the entire build is a few kilobytes of WebAssembly. The data layouts (a bitset grid, fixed-point positions, a derived trig table, a packed instance record) aren't micro-optimizations for their own sake; each one removed work or a whole class of bugs, and each is small enough to print on this page and poke at. That's the point of the approach: when the data is simple and explicit, the program is too.

Source for this chapter: sim.c · tanks_turn.c · tanks_move.c · collide.c · render.c · wasm.c · test.c · analyze_samples.c · README · CONTRACT