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

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.