Devlog

Notes from under the mountain

Decisions, and the reasoning that produced them. Mostly written down so the same argument does not have to happen twice.

A backup nobody has restored is a guess

There was no pg_dump anywhere in this project, and at some point that stopped being acceptable without anyone noticing the moment it did. The database now holds purchases, entitlements, leaderboard runs, login streaks and bug reports. The seed script can rebuild the catalogue and nothing else — every other table is real people's data with no second copy in existence.

The dump script makes exactly one hard promise: a file that appears under its final name has been fully written and has been read back by pg_restore --list. It writes to a .part file and only renames it into place after that read-back succeeds, so a truncated dump leaves no file behind at all rather than a plausible-looking one. A backup that looks fine and isn't is worse than an obvious absence of one — it is the thing you find out about on the day you need it.

The other half is the drill. A separate script restores a dump into a scratch database it creates itself, asserts the tables came back with rows in them, and drops the scratch database again; it never touches the source. Only the catalogue table is required to be non-empty unconditionally, because the seed guarantees it — an empty entitlements table early in a project's life is not a backup failure, and a drill that goes red for a non-failure teaches people to ignore red drills.

Deploy takes the dump before the migration, and a failed backup aborts the deploy. That is deliberate rather than cautious: the schema file re-runs on every deploy and is no longer purely additive, so the moment before migrating is the last moment the pre-deploy state still exists. "Migrate anyway, sort the backup out later" is precisely how the one backup you needed turns out not to have been taken.

What was not verified, said plainly: pg_dump is not installed on the development machine, so the Node wrappers were not run end to end there. The pure helpers were tested by direct import, and the dump-and-restore procedure itself was exercised through the database container's own client tools — ten tables, row counts matching, the --list gate and --exit-on-error both behaving. The wrappers' first real run is on the server. Writing that down is cheaper than discovering later that "backups are done" meant "backups are written".

One genuine defect fell out of building it: the restore-check script called its own entry point at module scope with no direct-execution guard, so merely importing it for its exported helpers demanded a database URL and then created and dropped a scratch database. An import must never do that.

The hardest wave in the game was the one after the boss

Monster health and damage grew linearly with the wave number, and every fifth wave was a scaled boss. Those two rules are each reasonable and together they produce a trap: the wave immediately after a boss is the hardest ordinary wave so far, and it arrives while you are still rebuilding from the boss. There is nowhere in a run to recover, so a loss usually starts one wave before it becomes visible — you die on wave 7 to a mistake wave 6 made unavoidable.

Waves now carry an intensity multiplier on top of the linear term: 1.15 on a boss wave, 0.75 on the one straight after it, 1 everywhere else. Checked against real spawns rather than the formula: wave 6 now lands below wave 4, and growth resumes at wave 7.

It scales health and damage rather than monster count, because a breather should be easier to survive without changing what it teaches — cutting the count would also cut the variety of shapes you have to answer, which is the part that keeps waves from feeling identical. One guard in that function looks defensive and is not: without a "later than the first boss interval" check, wave 1 also satisfies the after-a-boss test, and the single wave that is already trivial would have been the one handed a discount.

The HUD labels it (lighter wave). A pacing rule the player cannot see is just an invisible number, and this one is load-bearing information: the lighter wave is exactly the wave worth calling in early for the bonus gold. These are pacing values, not numbers tuned against playtest data, and the underlying linear growth was left alone on purpose — re-tuning the late game without data would be guessing dressed up as balance.

Undug rock that reads as blocks, for a few fragment instructions

Lifting the colour of unexplored rock out of near-black fixed "the screen is a black rectangle", but not the half that made it look like one. With no edges, a hundred identical dark tiles merge into a single silhouette and you cannot tell where one diggable tile ends and the next begins until you hover it.

The obvious fix — outline the tiles — is the wrong one here. The whole grid is three InstancedMesh objects on purpose, and putting LineSegments or a second pass over 484 tiles would give that up for a cosmetic gain. So it is a shader injection instead, and it needs no new data at all: box geometry gives every face UVs across the full 0–1 range and the texture is not tiled, so the distance to the nearest UV border is the distance to the edge of that tile's own face. No extra attribute, no per-instance uniform, no draw calls.

Seam strength is keyed to how dark the tile already is, which is what makes it self-balancing: unexplored rock gets a clear seam, while rock lit by the edge-light falloff or a gold seam keeps its look and gains only a hint. It sharpens exactly the region that was unreadable and leaves the atmosphere alone.

The first values were measurably too strong, and a screenshot caught it rather than an opinion did. A seam that wide on both axes lifts roughly a fifth of every face, so across a full undug map it stopped reading as seams and started reading as "the rock got brighter" — washing out the dark gradient that is a deliberate device, not a bug. Narrower and weaker keeps the block edges and leaves the mass dark.

Two things about the measuring are worth more than the effect. The before/after is one scene, not two page loads: the map is randomised per load, so two screenshots from two loads compare two different dungeons and can only ever be qualitative — hence the strength lives in a uniform that can be switched on a paused scene, making the effect the only variable. And the frame cost was measured, not assumed: 18.1 ms on the full preset against the 17.4–19.8 ms already recorded on that machine, 13.8 on low against 13.2–13.8. Inside the noise. The first measuring run was thrown away — p95 up to 78 ms, two variants flagged unstable, and "no shadows" somehow slower than "full", which is the non-monotonicity the measuring tool itself documents as the signature of a run you must not believe.

Selling the same thing twice

The catalogue has had a one_time column since the first schema, and nothing ever read it. Both storefronts — the site and the in-game panel — hid an already-owned item on the client, and the client is not where this belongs. A stale tab, a bookmarked link or a second window reached checkout and paid again, producing a duplicate entitlement. Not a security hole: a refund and a complaint.

It is fixed at three levels, because any one of them alone leaks.

  • Checkout rejects a cart containing a one-time item the player already owns — the whole cart, not the offending line, so nobody is charged for half of what they asked for. The owner now gets a 400 before Stripe is called at all.
  • Only non-revoked entitlements count as owned. That half matters more than it looks: a refunded player must be able to buy the thing again, and a plain "has ever owned" check would forbid that forever.
  • A partial unique index makes it a database invariant. The application check cannot cover two checkouts racing between the check and the webhook, nor Stripe's at-least-once delivery — the grant loop inserted unconditionally, so a redelivery double-granted. That loop now does nothing on conflict; without that, the index would turn a redelivery into a 500 and Stripe would retry it forever.

The order of statements in the schema file is load-bearing. It re-runs on every deploy, and creating a unique index fails outright on pre-existing duplicates — which would break the deploy instead of fixing the data. So duplicates are first collapsed to the earliest row (the conservative direction: the player keeps what they paid for), and only then is the constraint added. Verified against a real Postgres, including a dirty database seeded with three duplicate grants that migrated down to one and stayed idempotent across three runs.

Worth stating rather than implying: Stripe is still in test mode here, so nobody has actually been charged twice. The bug was real and the fix landed before there was real money to lose, which is a better sentence than the alternative and not the same sentence as "we caught it in production".

The default graphics preset was one a very common GPU cannot run

Measured on Intel UHD Graphics — integrated, extremely common, and the floor this game should still be playable on — the full preset renders a mid-game frame in 17–20 ms against a 16.67 ms budget, while the low preset holds 13.2–13.8 ms. So the shipped default was a preset that machine cannot run at 60Hz, and nothing told the player, because the quality toggle is manual and defaults to full.

The game now samples its own frame times and drops the preset once. Everything about it is timid on purpose, because a setting that changes itself is worse than a slow one if it ever changes for the wrong reason: at most once per session, never raising quality back (an auto-toggle oscillating around a threshold is unusable), never overriding a player who chose a preset by hand, ignoring the opening frames where shader compiles and model uploads land, and deciding on a median over 180 frames rather than any single one. Its unit tests are mostly about it not firing.

The threshold is 20 ms, and it only reads correctly once you account for vsync. In real play, frame times do not vary smoothly — they quantise to multiples of the refresh interval, 16.7 or 33.3 ms with very little in between. A median above 20 ms therefore does not mean "averaging 50fps", it means more than half the frames have already doubled to 33.3: roughly 30fps, which is worth acting on. The measuring tool disables vsync to isolate raw pass cost and so reports smooth intermediate values — about 19.9 ms for the full preset — and comparing that number against the threshold makes it look borderline. It is not, because the running game never sees unquantised times. Two measurements of the same thing, in units that are not interchangeable.

The tool that produced these numbers exists because an earlier measurement in the notes had to be retracted. It now refuses to print a result until it has disproved each condition that invalidated that one: a software renderer rather than a real GPU, vsync or the frame cap still on, background throttling, a page that is not actually visible. It also freezes the scene before sampling, because a wave arriving mid-run produced results where a cheaper preset measured slower than an expensive one.

A closed set of state names, and a watchdog for units that stick

A unit's state was a bare string with no enumeration anywhere, and that cost us twice in one cycle. Warriors and heroes froze permanently in engage because the give-up branch was unreachable. And adding two new states — resting imps, and imps walking to dropped gold — each required a by-hand audit of every place that enumerates "all states except…", which is the kind of chore that gets done correctly three times and then not.

What landed is deliberately not a transition table. The legal-transition graph here is dense and shifts with almost every behaviour tweak, so a table would be a second thing to keep in sync for very little return. What is cheap and worth having is two things: a closed set of valid state names per unit kind, and an upper bound on how long a unit may credibly stay in one state.

The tracker hooks the four update functions rather than the roughly 28 individual assignment sites. Comparing against the previous value catches every assignment automatically, including ones written later by code that never heard about any of this. It gives up the syntactic guarantee that assignments go through a setter — which is why an unknown state name is a runtime warning rather than a compile-time impossibility. That is enough for the actual risk, which is a typo'd state matching no branch.

The rescue is blunt: back to that kind's idle state, with path, target and dig claim cleared. Blunt is right, because the failure it exists for is a unit waiting on a path or a target that never resolves, and that is exactly what clearing them fixes. The time limits sit far above any legitimate duration — a false positive yanking a busy unit back to idle is worse than catching a real freeze a minute late. Idle states are exempt entirely (an imp with no seams left sits idle for the whole run, correctly), as are resting and digging, which are bounded by health and tile HP rather than by pathing.

Verified live three ways: a 261-second autoplay plus a 112-second healthy run produced zero rescues, so no false positives; an imp deliberately wedged mid-deposit and a warrior wedged in engage were both released with target and path cleared; and a bogus state name was reported. What it cannot catch, by construction, is a stall inside an exempt state — a monster forever seeking an unreachable heart stays in seek, since that state legitimately covers both walking to the heart and chasing whoever got in the way.

A room became a place, not a bag of tiles

The note asking for "a room as a whole object, not a bag of separate tiles" was partly wrong, and correcting it was most of the work. It claimed a compactness bonus was impossible without grouping — but the adjacency multiplier had been scaling every tile by the size of its connected component all along, and the room panel had been showing the player that percentage the whole time. A stale note that describes a missing feature you already shipped is worse than no note.

What was actually missing is narrower and more useful. The breadth-first sweep that builds those components ran on every cache rebuild, but only their size survived it — the components themselves were discarded at the end. So nothing downstream could address a single chamber: no tile list, no tile-to-chamber mapping. That, not the absence of grouping, is what blocked occupancy, a minimum working size, and any per-chamber identity. The same sweep now keeps what it already computed: no extra grid scan, one array per component.

Room counts still count tiles, on purpose. Every balance formula in the game is built on tile counts, and quietly redefining that primitive to mean "chambers" would have rewritten the economy as a side effect of a refactor. Groups are a layer on top, not a replacement.

The first thing to use them: the room panel now describes the chamber you clicked rather than the tile, including how many of your creatures are standing in it. That measures presence, not assignment — nothing in this game assigns a unit to a room, units drift in and use it while they are there — so presence is both the honest answer and the one that stays correct without new state to keep in sync. Saying "empty right now" out loud is the point: a Lair nobody sleeps in looks identical from the outside to one that works, and both cost the same gold.

The dungeon started explaining itself

All the teaching this game did fitted in one place: a seven-bullet list in the modal that opens before your first run. Nobody reads a seven-bullet list, and nobody who does remembers item five ninety seconds later. That was survivable while the rules were visible on their own, and it stopped being survivable as soon as several mechanics landed whose consequences you cannot see happening.

Gold over the storage cap is discarded silently. A payday you cannot afford makes a unit walk out. A wounded imp with no Lair to rest in never heals again for the rest of the run. Gold dropped by a dead imp just lies on the floor waiting for someone to notice. Every one of those was a thing the player could only learn by losing to it, and none of them announce themselves — that is what makes them different from "the Heart is at 20%", which the HUD already shouts.

A real tutorial is a much larger piece of work than the problem justified, so the shipped answer is a single line of contextual advice that says the most useful thing about the situation you are actually in. Three rules keep it from becoming wallpaper. One message at a time, the most urgent that applies — five simultaneous hints get tuned out as a block, one line that changes when the situation changes gets read. Every rule has to be actionable right now, so "payday in 9s and you are 14 gold short" qualifies and a restatement of a number qualifies for nothing. And silence is the default: a player who is doing fine should see an empty space, not reassurance.

The rule that nearly sank it was the most obvious one — no imps means no digging, no gold, and no way to earn the gold that buys one back, which sounds like the highest-priority thing in the game. It fired in the opening seconds of every single run, because a run legitimately starts with zero imps and the Heart spawns one by itself a few seconds later. The first thing the advisor would ever have said to anyone was a demand to fix something that was already fixing itself, which is the fastest possible way to teach a player that this line is noise. It now also checks that you cannot afford the recovery — so it speaks only in the one situation where its advice is genuinely the next move. Playing the thing is what caught it, not reading it — the condition is correct in isolation and wrong in context, which is the category of bug that only shows up on the screen.

The rules are a plain ordered array of { id, severity, when, text } objects rather than an if-chain, which was worth doing for two reasons: the array's order is the priority, so the whole scheme is readable in one screen, and the module pulls in no DOM and no renderer, so every rule — including the one above, now — is pinned by a test that runs without a browser.

A warrior that never gave up, and gold that never landed

A warrior would occasionally stop for good — standing in a corridor, not fighting, not returning, not healing, for the rest of the run. The engage state is a small chain: if the target is close enough, fight; else if there is still a path, walk it; else give up and go back to guarding. The middle arm called stepAlongPath and threw its return value away, and stepAlongPath never nulls ent.path itself. So the path stayed non-null after it was fully walked, else if(w.path) won forever, and the give-up arm was unreachable code.

The part that makes this not an edge case: the approach path is built once, toward the tile the monster occupied at scan time, and the monster keeps moving. Running the path out while still out of reach is the normal outcome, not a rare one. Nothing threw, nothing logged — a unit just quietly left the game. The fix is one line consuming the return value, but the reason it hid so long is that a motionless warrior reads as bad pathfinding rather than a dead branch.

Found in the same pass, unrelated except in kind: an imp killed while carrying gold destroyed that gold. A Master Digger hauls up to 250, so an ambush on the walk back to the treasury could erase a large slice of a run's income with no message, no marker, and no way to know it had happened. Now the load falls on the floor where the imp died, gets a world marker, and any imp will path to it — checked before it looks for a fresh dig, and before the carry-capacity gate, so an imp passing a pile banks it even when it is nearly full. That turns a silent subtraction into a decision: go back into the tunnel the monsters just came out of, or write it off.

Rooms the units actually walk into

Two complaints about rooms turned out to be the same complaint. The first: upgrading a room was never a decision — every level was strictly better than the last, so you clicked it whenever you could afford it. All seven room types now branch on their final upgrade (level 2 to 3), and the choice is permanent per room. A Treasury becomes a Vault (more capacity) or a Coffer (more income). A Barracks becomes a War College (stronger warriors) or a Quartermaster (cheaper ones). Library, Forge, Lair, Hatchery and Training Room each got a pair too. Some branches stack per tile, some are a single global toggle — a spell discount or a bonus per gold seam isn't attributable to one particular room, so making it stack would just mean building six libraries for free spells.

The second: a Training Room only raised the warrior headcount limit, and idle warriors just stood wherever their last fight ended. Both now resolve into one behaviour — a warrior with nothing to do and no rally point walks to a Training Room and slowly earns the same veterancy XP a kill grants. No parallel progression system, just another source for the existing one.

The interesting constraint is the cap: training stops at Veteran and cannot reach Elite. Without it, every warrior drifts to Elite for free given enough quiet time, and then kill-earned veterancy — plus every "losing a veteran hurts" line built on top of it — means nothing. It is also deliberately slow: at 1/45 XP per second and Veteran costing 3 XP, that is roughly two and a quarter minutes of uninterrupted standing for one promotion, against a wave arriving every 45 to 55 seconds. Training is a use for genuinely idle time, not a shortcut past fighting.

The Lair had the mirror-image problem — it raised the imp cap by a number and imps were never in it. A badly hurt imp now drops what it is doing and runs for a Lair (the heart stays the fallback if you haven't built one), and rests there until it is back to 90% before returning to work. Resting is its own state rather than a heal bolted onto idle, because an idle imp would grab the next dig job on its following scan and walk out of the lair at 46% health, having gained nothing.

Why a Dungeon Keeper-like, and what already works

The pitch is short: you never control a hero directly, you build the place a hero has to survive. That premise has not had a real home in the browser — most dungeon-builders either need an install or drop the "keeper" framing entirely and hand you a party to move around. ImpKeep keeps the framing and ships as a page, not a download.

What is already playable, not planned: digging out rock to find gold seams, seven distinct room types with adjacency bonuses, imps that carry the whole economy on their fragile backs, warriors that rank up from Recruit to Elite through actual kills, a rare hero unit with a cleave attack and per-run XP, and monster waves that escalate by composition — Grunts, then Stalkers, then Brutes — with a scaled boss wave every fifth one. A payday mechanic taxes gold on a timer so "build everything and stop thinking about it" is never a safe plan.

None of that is a vertical slice standing in for the real game — it is the real game, running at impkeep.com/play right now. What is not here yet: a save/load-backed campaign of sequential missions rather than a single scored run — the game still ships one map with one objective.

Updated August 2026. This entry originally also listed the leaderboard as waiting on a backend. That backend exists and is deployed: runs are submitted per mission and difficulty, the game-over screen shows your rank, and the top of the table is on the front page of this site. The wave description above has since gained a wrinkle too — growth is no longer a straight line, see the wave-pacing entry.

Heroes, and why bigger numbers were not enough

The obvious way to add a rare unit is to take the warrior, multiply its health and damage, and call it a hero. We tried that on paper and it read as a warrior you were told to care about, which is not the same thing as caring about it.

So the hero got a cleave attack — it hits multiple adjacent enemies. That changes where you want it standing, which changes how you fight the wave. It also earns XP and levels within a run, so a hero that survives four waves is measurably the hero that survived four waves.

Progress is per-run on purpose. Persisting hero levels across games would have made the first mission of a campaign an obstacle between the player and their real character.

Levels became a campaign, not a lift shaft

"Multiple levels" originally meant vertical floors: dig down, find a deeper tier, repeat. It is a great pitch and a bad fit. The camera work is awkward, the pathfinding gets an entire extra dimension of edge cases, and the reward for descending is mostly that everything is the same but larger.

Sequential missions give the same sense of progression with none of that, and they let each map state its own objective. The cost is that saved progress stops being optional — a campaign you cannot resume is not a campaign. Current status is on the "where it is going" section of the game page.

The simulation stopped depending on your frame rate

Three separate complaints turned out to be one bug wearing different hats: the game did too much work per frame, and tied the wrong things to how often frames happened.

  • The simulation stepped once per rendered frame, so a 144Hz monitor ran a faster game than a 60Hz one. It now runs at a fixed 1/30 step regardless of what the display does.
  • The HUD redrew sixty times a second to display numbers that change a few times a second. It is throttled to 12Hz.
  • The minimap was rebuilt from scratch every frame. It is cached.

None of this is visible as a feature, which is exactly why it was worth doing before adding more that would sit on top of it.

The first two real models: a heart and an imp that hesitated

Every unit and room in ImpKeep started as procedural Three.js geometry — boxes, cones, nothing hand-modeled. That was a deliberate floor, not a target: no external assets meant nothing to fail to load, and every mesh was already a plain Object3D, which meant a real model could slot in later without a rewrite. The Dungeon Heart was the first thing swapped for a Meshy-generated .glb, then an animated imp followed. Both load through the same loader with the same rule: if the file is missing or broken, the game keeps the procedural shape and never breaks.

The imp shipped with four clips — walking, running, attacking, idle — driven by THREE.AnimationMixer and cross-faded between AI states: digging plays the attack clip, fleeing plays run, moving plays walk. Clips are matched by keyword rather than exact name, because different export services name them differently ("Walking" here, something else entirely from the next source), and that one decision means adding the next animated unit will not involve renaming anything.

The one real fight was measuring a skinned model's height at runtime. Box3.setFromObject reads the bind pose, not what actually renders — first the imp floated a third above the floor, then a fix attempt made it briefly enormous, because a skeleton's bone matrices are not settled the moment a model finishes loading. The fix was to stop trying: height is now measured once, offline, by the same tool that compresses the file, and baked into a constant the runtime just trusts. Compression mattered too — almost all of a model's weight turned out to be textures rather than geometry, so the imp went from a 6.82 MB export to a 279 KB file the game actually ships.

Updated August 2026. The heart and the imp were the first two, not the last two. The same loader now carries every room type, every monster type, the warrior, the hero, doors, the torch, the trap, and the rally and command props — all real .glb models with the same fallback rule: if a file is missing or broken, the procedural shape still renders.

More coming. These are hand-written entries on a single page, dated to the month they were written rather than to a publication day that never happened. A real post list — day-accurate dates and a feed you can subscribe to — arrives when the writing is keeping a schedule worth subscribing to.

Read enough?

The dungeon is one click away.

Enter the dungeon (opens in a new tab)