Loading demo...
canvasgamessimulationprocedural

Top-Down Roguelite

A hack-and-slash roguelite built on a bare 2D canvas — fixed-timestep simulation, seeded runs you can replay exactly, three floors and three bosses, three weapons, a meta layer, pixel art from a script, and not one line of game engine.

A top-down hack-and-slash roguelite running in 960×540 of canvas. No Phaser, no PixiJS, no ECS library, no physics library, no sprite tool — the loop, the collision, the room generation, the enemy steering, the renderer and the art pipeline are all hand-written under src/lib/roguelite/, and the whole thing adds nothing to this site's dependencies.

It's a complete run, three floors deep. Eight rooms arranged procedurally, doors that seal behind you until a room is clear, a cache that heals you, and a boss at the end — then a landing, a keystone, and a stairway to a floor that is shorter, denser and meaner than the one above it. Five enemy types plus elite variants, ground that burns and spikes that cycle, and three bosses that share a pattern table and nothing else. You pick one of three characters — sword, spear or hammer — and stack upgrades onto that weapon until the steel visibly changes colour. Whether you win or die, the run pays out embers, and embers buy the other two characters, permanent stats, locked keystones and rares, and a Descent that starts you on floor 2 with a keystone already in hand. Then you go again with a different floor.

Three floors, one table

A floor is a row in a table: its shape, its name pool, its room templates, its wave ramp, its HP scale, its palette and what it pays. Floor 1 is the original slice to the digit — the headless harness records the running hash on the step the first boss dies, and every checkpoint since has asserted it unmoved. Floors 2 and 3 are rolled from the run's own random stream after every draw floor 1 made, which is what lets a seed that used to reproduce one floor now reproduce three without the first one moving.

The bosses are the same idea. A boss is an id, a list of pattern ids and a list of phases, and the behaviour reads the list and switches on nothing. The Warden and the Hollow King share a fan attack by both naming it; the Kindler keeps its distance and fights with ground; the King rebuilds the arena around you at two-thirds health by making four footprints solid. Ground hazards are one zone system with four kinds and three sources — the Bomber's blaze, the Kindler's embers, the templates' vents and pits, and your own Scorched Step trail all go through it.

Keystones are the build. After each of the first two bosses, the landing heals you and offers three, flat, from nine. They're upgrades in every way but the draw — same shape, same hooks, same HUD list — which is why Bloodpact can move two numbers on one card and Echo can be a stat the weapon's own phase machine replays. Rares appear only on floors 2 and 3, and an evolution appears when you hold both its parents — substituted into the last card slot rather than drawn, so the offer is still a choice.

Fixed timestep

The simulation runs at exactly 60Hz on an accumulator, independent of your monitor. Rendering runs free and interpolates on the leftover. This is the difference between a game that plays the same on a 60Hz laptop and a 144Hz desktop, and one where the dash distance depends on your hardware.

The accumulator is clamped to five steps per frame. Without the clamp, a tab-switch hands the loop a multi-second delta on return, which it then tries to simulate all at once, which takes longer than a frame, which grows the next delta. Games die that way.

Everything with a duration is counted in whole sim steps rather than seconds, which is why the dash covers exactly 220 pixels, the sword's finisher knocks back exactly 64, and the boss's charge travels exactly 264 — on every machine and every replay.

Seeded runs

Every run is generated from a single seed and nothing in the game calls Math.random(). The seed sits in the corner of the screen — click it to copy — and the URL carries it along with the character you're playing, so a link reproduces the run and not just the floor. The same seed and the same character produce the same floor, the same enemy placement, the same upgrade offers and the same boss pattern order, every time. When you die, one button replays the seed.

The unlocks you've bought change what can appear in the upgrade pool, so they're folded into a run once, at the start. Buying something mid-run can't reach the run in flight — not because nothing writes to the wrong field, but because the pool was settled before the run existed.

That's mostly a debugging tool, and a headless one: a harness replays four seeds for three thousand steps with each of the three characters and hashes every combat number along the way. When a checkpoint is supposed to be presentation-only, the hashes prove it; when a checkpoint changes combat on purpose, the hashes move and get re-recorded with the old values kept for anyone bisecting.

The floor generates as one search

Eight rooms: a start, five fights, a cache, a boss. A spine of six runs start to boss as a self-avoiding walk on an invisible 5×3 grid, with two dead-end branches hanging off its interior.

The first version chose the spine and then the branches, as two separate searches. It looked fine. It was wrong on 10.3% of seeds — the spine would wander into a corner and leave nowhere for a branch to hang, producing a seven-room floor with nothing failing loudly. No error, no warning, just a floor quietly missing a room.

The fix was to make it one backtracking search: a spine isn't accepted unless it leaves enough distinct free cells for its branches. Two thousand seeds later, zero malformed floors, every door reciprocal, the boss always deepest and always a leaf. Worth noticing that the bug was invisible from the inside — the only way to see it was to generate thousands of floors and assert on the shape of the results.

The minimap reveals the floor the way you walk it: a room you've stood in, and the rooms a door leads to from there. The cache and the throne room aren't marked until you find them, for the same reason room names are drawn from a pool — the floor shouldn't give itself away.

Three weapons, one combat system

The sword is a three-hit arc. The spear is a thrust, twice the reach and half the forgiveness. The hammer is two hits, both committed, and the second is the longest freeze in the game. None of that is special-cased anywhere: a weapon is an object declaring a hit shape — arc or thrust — and a list of hits, and the combat code asks the shape whether it connected. The hammer having two hits instead of three was a data change. There were exactly two places that assumed three, and both were reading the list's length.

The characters differ in body, not just weapon. Blade is the baseline. Lance is frail and quick, which is what turns the spear's reach into a real choice. Maul is slow and heavy, and the extra hearts are what pay for a combo you can't walk out of.

Upgrades are data

An upgrade is an object in a table. It either moves a number — damage, reach, arc, knockback, attack speed, dash charges — or it names an effect that hangs off a hook, like the crit that doubles a hit or the finisher that throws a shard. Nothing in the combat code knows any specific upgrade exists; stats are read live at the point of use and effects are dispatched by the hook they registered on. A few upgrades are tagged for particular weapons — Broadhead widens the spear's blade and means nothing to a hammer — and the pool narrows by tag at setup.

The rule that makes the whole thing work is that an upgrade never mutates the tuning tables, which are module-level and shared by every run in the tab. Mutating them would leak into your next run and quietly break the replay guarantee — and a fresh page load would look completely fine, so nothing would catch it.

The tell is the truth

Every telegraphed attack locks its facing the instant the tell appears, and then commits to exactly what it drew. The charger paints the lane it's about to travel and travels it — measured at zero lateral drift, which means sidestepping is always enough. The slinger holds still and draws a dashed line before it fires, and the bolt leaves along that line. The boss's slam draws the precise circle that will hit you. Its fan draws one ray per bolt rather than a vague cone, because "something is coming from over there" isn't enough information to step between two of them.

This is the one rule the whole fight rests on. An attack you can't read isn't difficult, it's unfair, and the difference between the two is entirely a matter of whether the drawing tells the truth. Every boss pattern was measured against the worst position it can catch you in, and every one is escapable by walking, at base speed, without spending the dash. The dash is margin. It's never the answer.

Phase two shortens every tell rather than adding new ones you haven't seen. Same information, less time to act on it.

Enemies steering around cover took three tries

Combat rooms are furnished, and the blocks are cover for both sides — a slinger's bolt dies on one, which cuts both ways, since a slinger that backs into the lane behind a block can't shoot out of it either.

Getting enemies to walk around furniture failed twice, silently, before it worked. A lookahead whisker alone pins an enemy against any face wider than the whisker can see past: it keeps aiming into the surface, the push-out cancels the motion exactly, and net travel is zero. Adding a slide along the face wasn't enough on its own, because without committing to a side, the cheaper-looking way round flips as the enemy drifts and it oscillates in place forever.

The answer is all three together: commit to a side, probe it, slide along the face when nothing on it is clear, release when the direct path reopens. Neither failure threw anything. Both just produced an enemy standing still, looking approximately like it was thinking.

Juice that can't change the run

Hits throw sparks, kills burst, the hammer's slam freezes the picture for eight frames and shakes it. All of it comes off a one-way outbox: the simulation writes hit, kill, hurt and dash into a pre-allocated queue and never reads it back. The renderer drains it, decides what colour and how many, and the sim can't tell whether anyone was watching. That's what makes the juice unable to change a run by construction rather than by care.

Hit-stop lives in the loop, not the sim. A freeze that skipped sim steps would shift the step sequence and break the replay guarantee, so instead the loop simply stops asking for steps for a stretch of wall-clock and drops the held time rather than paying it back. The harness asserts that a seed plays out identically with the juice on and off, and it does.

The art is a script

There's no artist and no sprite tool I trusted to produce a consistent set, so the sprites are generated by a script that places pixels deliberately: 24-pixel bodies over a locked indexed palette, four directions, a walk and an idle, and a contact sheet that shows every cell at native size — because judging pixel art at 4× is how you ship sprites that are mush at 1×. The PNG encoder is node's zlib plus about sixty lines.

The one rule the sprites keep is the one that used to keep them out: what you see must be what can connect. The swung weapon isn't a pose. It's placed by the attack's own live numbers — hilt at the player's radius, tip at the reach, along the angle the sweep has actually reached — so an upgrade that adds reach genuinely makes the blade longer. The middle of the blade is a one-pixel column stretched to fill, which at nearest-neighbour is N exact copies of that column, so a blade is 35 pixels on one run and 65 on the next without a scaled sprite anywhere. Stack enough weapon upgrades and the steel changes colour.

The enemies came later, once the seam between a sprite and three flat circles was the most visible thing in the game. They're creatures rather than recoloured people, sized to their collision circles, and the boss needed a cell of its own. The telegraphs stayed geometry: an enemy on screen is now three layers — a lane or a ray underneath, the body, a pip or a ring on top — and the body mid-tell is the same cell recoloured telegraph-yellow with its outline kept. The lines that decide whether a fight is fair didn't move.

Everything else is DOM

The health, the dash charges, the ember count, the upgrade list, the minimap, the menus and the upgrade screen are all HTML sitting over the canvas, which makes them focusable and screen-readable for free. The canvas draws gameplay and nothing else. Keyboard input is scoped to the game while it has focus — Space is both the dash key and the page-scroll key, and the page only loses it while you're playing — and nothing autoplays: the title card is a real frame of the start room with your character standing in it, and the game holds there until you press start.