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.