Delve: Between Descents

Context

Delve is a browser roguelike. Ashes of the Pale Flame is its deckbuilder spin-off. Between Descents is the third game in the same world, this time asking a different question: what’s it like to be the town above the dungeon? You never go down. You manage the operation that sends people who do.

The constraint that made it interesting: the game had to share lore, art, classes, and flavor text with the other two games — any inconsistency would be visible immediately to anyone who had played them.

Problem

Town management sims tend to grow into sprawling systems. The goal here was the opposite — a tight loop that resolves in a session, feels consequential on each decision, and doesn’t require a feature roadmap to be worth playing.

The challenge was keeping the loop meaningful without a real-time simulation. Delvers go out; they come back (or they don’t). Every decision — which depth to send them to, whether to press on at a checkpoint, what to equip — needs to feel like it matters even though the resolution is a single instant roll-up.

Approach

1. The town loop

The core is simple: recruit → equip → send → recover. Loot that comes back lands in a shared Stash rather than auto-equipping — you choose who gets it or sell it for gold. Shops have three investment levels that unlock deeper stock and, with it, deeper expedition options. Buying a replacement item auto-trades the old piece in at half price rather than discarding it.

2. Mid-delve checkpoints

Shallow expeditions resolve in one go. Anything deeper splits into up to three checkpoints: the delver returns with their log and current gold, and you choose to press on or bank what they have. Dying mid-phase ends immediately. The checkpoint system is what turns a simulation into a game — each pause is a real decision, not just flavor.

3. Expedition log

Each expedition generates a beat-by-beat narrative: faction lore lines, named enemy epithets, and depth flavor pulled from the same tables as the roguelike. The log makes the abstract simulation legible — you’re not watching a number go up, you’re reading what happened. The same lore voice that shows up as in-dungeon graffiti in the roguelike appears here as faction beats from the Order of the Pale Flame and the Thornwatch.

4. Hall of Winners and burial ceremony

Cross-run persistence happens through the Hall of Winners: every delver who falls or retires is recorded with their name, class, deepest depth, times delved, and fate. A “Reset run” wipes the current town but keeps the Hall — so there’s a sense of accumulation even when you start fresh.

When a delver falls, the game doesn’t skip past it. The expedition log reports the death, then a burial ceremony gives them a procedurally-picked epitaph in the same dry, unsentimental tone as the roguelike’s in-dungeon inscriptions, before the roster slot opens up.

5. Shared world without shared code

The three games share data files (classes.ts, items.ts, flavor.ts, epitaphs.ts) and character portrait assets, copied rather than pulled from a monorepo — intentionally loose coupling so each game can diverge without a dependency chain. The shared world is a constraint on content, not on code.

6. Save integrity

Same SHA-256 signing scheme as the deckbuilder: the full save state is serialized, a hash is computed with a known salt, and on load the hash is verified before the state is applied. Hand-edited saves are rejected. The implementation uses Web Crypto (crypto.subtle.digest) — no library required.

7. Competitive mode

The mechanic: two keepers, one dungeon. The game ID doubles as the RNG seed — both players fight the same floors with identical encounter rolls, but make independent town decisions. First to outlast the other wins.

The seeded RNG problem. Math.random() is stateless and per-tab — there’s no way to make two browsers produce the same sequence. The solution is a mulberry32 PRNG threaded through every call site in the expedition engine: resolvePartyPhase and finalizeParty now take a rng: () => number parameter that defaults to Math.random for solo play. A seeded version is constructed server-side from the game ID using FNV-1a hashing.

Server-side resolution. In competitive mode, expeditions are posted to a Fastify backend instead of running in the browser. The server runs the same pure functions (copied into server/src/expedition.ts, no browser globals) against the shared dungeon state and returns the result. This ensures both players see the same floors regardless of play order.

Shared dungeon state. The server maintains a dungeon_state JSON blob per game: which rooms have been cleared, by which player, with what outcome. The Rival Town panel reads this to show color-coded cleared rooms — green for yours, red for theirs.

WebSocket catch-up. On connect, the server streams all past events as a replay. Live events broadcast to both players as they happen. The WS endpoint doesn’t maintain presence — it’s delivery-only, so a player who reconnects after being offline gets the full catch-up without any server-side session state.

Smoke tests. Eleven tests cover RNG determinism, expedition engine reproducibility, and a full two-player HTTP lifecycle via Fastify’s inject API — no running server needed. The critical assertion is that two runs from the same seed produce identical depthReached, gold, and deaths.

What’s missing

  • Checkpoint path choices (careful/reckless) in competitive mode — currently auto-resolved as “careful” since the pause UI is client-side only.
  • Shop investment discounts aren’t reflected in the wording as clearly as they could be.
  • The rival crew in the expedition flavor is still pure fiction — no actual competing party state exists in solo mode.

Stack

LayerTech
ClientVite + React + TypeScript
Competitive serverFastify + @fastify/websocket + Node built-in SQLite
StylingPlain CSS — dark/gold palette and Cinzel/JetBrains Mono shared with the other Delve games
Persistence (solo)localStorage, SHA-256 signed via Web Crypto
Persistence (competitive)SQLite, one row per active game
DeployDocker (static nginx), built and pushed by Gitea Actions