← All posts

Warpath: the aim preview is a second physics engine

Gjorge Karakabakov

Play it: warpath.idad.fyi - browser, no install, no account. Progress lives in your own localStorage.

Warpath: a stone hall in wine and gold, a siege engine loosing a ball along a dotted arc toward a peg field shaped like a crown, with coin holes along the floor

Every Peggle-shaped game has to answer one awkward question early: how much of the shot do you show the player before they take it? Draw nothing and aiming is a shrug. Draw the whole flight and you have promised something the physics engine has not agreed to.

Warpath is my answer, wrapped in a siege. You are the engineer the crown could spare, walking a road of holdings that have all gone quiet, with one trebuchet and a cart of river stones. The opening screen puts it better than a summary would:

The relief column never came.

Forty-eight boards, each with a coin quota and a fixed number of throws. Meet the quota before you run out of stones and the road opens one holding further.

The aim preview is a second Matter world

The obvious way to predict a bounce is to reflect the ball’s velocity about the contact normal and scale it by a restitution coefficient. That is the textbook answer, it is twenty lines, and in this game it is wrong.

Matter resolves collisions from an overlapped state, across eight position iterations. At a launch speed of about 16 pixels per step, the ball covers half a peg every frame, so by the time the solver looks at the pair, the normal it works with is nowhere near the geometric entry normal you would compute by hand. Measured against real shots, my reflection kicked the ball up off a peg the engine glanced it straight past. Then the error compounds with every further bounce.

So core/shadowTrace.ts stops approximating and runs the thing instead. A second, headless Matter engine mirrors the board’s static bodies, a ghost ball gets launched into it, and it is stepped with the same fixed delta, the same gravity and the same iteration counts as the live world. The path it draws is what will happen, because it is the same computation.

The shadow board is re-synced whenever the real one changes, since a peg you cleared is a peg the prediction must stop bouncing off.

One ricochet, because one is what can be told truthfully

Running the real solver tempts you to draw the whole flight. I measured before deciding: across three boards and eighteen angles, the first ricochet lands within 12px of where the ball actually goes, median 3. The second has a median of 6 and a worst case of 208 - most of a board away.

That worst case is not solver noise. By the second bounce the shot has usually met a gargoyle firing a scripted impulse, a barrel that shatters on its second hit, or a moving hazard the prediction froze in place. So bodies carry a predictable flag, and the trace stops at the first one that lies, because past that point the honest answer is that nobody knows. The preview draws one tail past first contact.

Drawing the second arc was the change that made the preview feel like it was lying, which is a strange thing to fix by showing less.

Every board is a picture

A field of scattered pegs is functional and completely forgettable. So each level’s pegs are laid out from a 12 by 11 grid in level/pegArt.ts, where # is a peg, o is a cell that would rather be gold, and . is nothing:

'.....oo.....',
'.....##.....',
'.....##.....',
'.....##.....',
'..o######o..',
'.....##.....',

That is The Sword. There are fourteen of them - a crown, a skull, a wolf, an anvil, a raven, an hourglass and the rest - and each board gets one deterministically, so a level always draws the same picture. It also carries more pegs than my old scatter did, and it gives every board an identity before the first throw.

The biome still owns everything else: the palette, the hazards, the perils, the dressing.

The board is permeable by construction

PEG_GAP is the ball’s diameter plus nine pixels, and every placement routine honours it. The point is that the ball can always fall between any two obstacles, which is a property of how boards are built rather than a thing that usually works out.

A generated board then goes through a validator before anyone sees it. It rejects boards with too few pegs, too little gold, any overlap, a peril sitting on a peg, or a quota steeper than 62% of what the board could theoretically pay out. The interesting check is porosity: it walks a horizontal scanline down the field every 10 pixels and asserts that each one leaves at least one gap a ball can pass, counting moving hazards at their full swept extent because they will eventually be there. A board that fails is thrown away and regenerated with a fresh seed, up to twelve times.

Two tests hold the line: one measures the tightest surface gap between every pair of pegs on all 48 levels, and one asserts every mandatory pickup has a direct shot from the launcher, using the same collision model the generator validates against rather than a second approximation of it. Those pickups get their flight lanes reserved before the peg field is generated, so the field is simply forbidden from closing them. Carving a route out of a finished board afterwards is the version of this that fails at 3am.

A wide window gets a wide board

The world is 1200 tall with a width taken from your window, so an ultrawide display gets game rather than two fat letterbox bars. Levels are still generated in a canonical 900 wide space and the finished layout is stretched horizontally to fit, because a seeded board has to be the same puzzle on a laptop and a 34-inch monitor, and it would not be if the generator itself saw a different field width.

The stretch is capped at 1.5x. Past that a fixed peg count reads as empty and the far corners get awkward from a launcher fixed at the top centre, so any extra width becomes stone wall either side. It is also never below 1x, and that is the load-bearing half: scaling x outward can only widen the gap between two obstacles, so the spacing guarantee survives the stretch untouched instead of needing its own proof. A window narrower than the canonical aspect letterboxes vertically rather than pinching the board.

There is a test that re-runs the spacing assertion at five window widths from square to ultrawide, because “it looked fine on my monitor” is not a guarantee.

Balls that never come down

Matter runs a fixed 60Hz step with delta smoothing and snapping off, which keeps seeded boards reproducible and is also what lets the aim preview integrate against exactly the same numbers. Ball speed is clamped below half a peg diameter per step, so tunnelling through an obstacle is impossible rather than rare.

That leaves the opposite failure: a ball that will not retire.

A ball dawdling in a pocket between round pegs gets one nudge after 700ms, early and well inside the window that would kill it. Round pegs drain, so a shove is usually all it needs, and blowing up a throw that would have carried on is worse than waiting another second.

A ball resting on a flat platform is a different problem. A drawbridge, a portcullis bar or the cart rim carries the ball, so it never drains and a nudge just drops it straight back on. Those get blown up instead, and the drift that decides it is measured relative to the platform: a ball being carried 100px upward by a rising drawbridge counts as stuck, while a ball rolling across one does not. Contact is also allowed to lapse for a third of a second, because a micro-bouncing ball drops in and out of Matter’s active pair list while being exactly as stuck as one lying still.

Everything else falls to a blunter rule. A ball that has not moved 15px in two seconds is gone, air drag ramps up after six, and nothing lives past sixteen. Wedged between pegs, balanced on a hazard and orbiting a pocket are three different bugs and one identical experience, which is watching a ball you have no further input on.

No image files

There is not a single sprite in the repository. theme/artgen.ts draws every peg, barrel, gargoyle, coin hole, cog, banner and frame once at boot into a 2D canvas and bakes it to a texture, about 2,900 lines of it. Each shape function takes a palette, so the same geometry produces six visually distinct biomes and a new one costs a palette entry and a level generator rather than a sprite sheet.

Sound is the same trick with oscillators: peg blips whose pitch climbs a fixed ladder as your chain builds, filtered noise for barrel breaks, a Karplus-Strong pluck for the ball. The one recorded asset in the whole project is the tavern ambience playing underneath it.

The same generator also draws your banner. Ten heraldic colours and twenty devices, picked once before the first siege, flown over every board from then on. It changes nothing mechanical. A campaign about retaking somebody else’s road reads differently when the banner over the board is yours, and a first-run choice, however small, makes a save feel like a save rather than a slot.

The ordering that makes the easy setting harder

Difficulty is three presets, and every knob is a multiplier on something the level builder already computes, so a preset can never generate a board that fails validation. It only shifts how much slack you get. Squire strips caltrops out entirely, Warlord takes a throw away and raises the quota by 18%.

The quota is built from the obstacle field plus roughly one hole payout per ball, and then discounted for how many caltrops are on the board, since every spike is a chance the ball dies before it reaches a hole. Without that discount, adding perils would quietly make every level harder than its own quota assumes.

Which sets a small trap. Count the spikes after difficulty has touched them and Squire, which removes perils entirely, loses their quota discount along with them - the gentlest setting then asks for a larger quota than Knight. So the count is taken at baseline and difficulty scales that one number afterwards. The quota is a property of the board and difficulty is a property of the run, and they have to be applied in that order or the easy mode quietly becomes the hard one.

Difficulty is also asked once, when you start a campaign, rather than parked on the title screen. It is a decision about this run, not a setting.


Boards are fully deterministic, so a seed rebuilds exactly the same layout on any machine, and a backtick opens a dev panel that will drop you on any of the 48 holdings from a fresh save. It is player-facing rather than hidden behind a build flag, because playing your way back to a bug is not a debugging workflow, it is a punishment.

Take the road at warpath.idad.fyi. Forty-eight holdings, six biomes, no image files, and a dotted line you can actually trust for one bounce.