INTRO
A solo dungeon crawler built to test one idea: that a server-authoritative architecture is worth it from the start, even for a single-player game. When I added real-time co-op later, no file was ever deleted or renamed to make room for it, though existing services still had to grow to carry more than one actor.
The scope was deliberately broad: procedural generation, D&D-adjacent combat resolution, real-time state sync, and a persistent shared world, backed by close to 300 server test methods that run as over 400 executed test cases. I wanted a project whose decisions I could defend, not just screenshot.
SECTION
The architectural bet
I put gameplay logic on the server, not the client. The ASP.NET Core server owns every truth: movement legality, line-of-sight, dice rolls, item drops, and which entities each connection can see. Each connection gets its own server-built snapshot, so enemies, chests and corpses outside a player's line of sight never ship to them. The full tile grid, rooms and death heatmap ship every tick regardless; fog of war is a parallel visibility array the client applies as a shading overlay on top of that map, not a filter on what leaves the server. The React and Pixi.js client sends intent and renders state diffs over SignalR.
That paid off unevenly when co-op arrived. Fog of war was already per-player, the animation pipeline already consumed structured combat events, and persistence for corpses and world stats was already isolated, so those pieces held with no changes. Others didn't: the multiplayer commit touched 81 files, and the combat service's public API had to reshape around a multi-actor `ActiveCombat`, growing from 355 to 517 lines. What held across the whole history is architectural rather than incidental: zero files have ever been deleted or renamed, so this expansion, like every other, built on what existed instead of replacing it.


SECTION
Procedural generation that's testable
Floors come from BSP partitioning, deterministic per seed. The Generation project has no I/O, no DI, and no hidden state, though it stops short of a strict pure function: entity ids are drawn from `Guid.NewGuid()` at more than a dozen call sites, so the determinism tests compare tile types and room bounds rather than asserting object equality. That's what let close to 300 test methods, some parameterized across seed ranges into over 400 executed cases, cover BSP layout, field-of-view, movement legality, engagement, combat, item interactions, and descent end to end.
Combat uses a `ScriptedDice` test double, so outcomes that are random in play become fixed on demand, and a flaky test points to a real bug. Because the same seed produces the same dungeon every time, a run is fully reproducible from its seed and shareable as just that seed.

SECTION
Combat as an event stream
Combat could have been a number change on the server and a re-render on the client. Instead the server emits a structured event stream (Hit, Crit, Miss, Fumble, Heal), each carrying target, magnitude, and timing, and the client plays it back as an animation queue.
Hits lunge and flash red. Crits add camera shake. Misses sidestep. Heals pulse green. Killing-blow sprites hold off on destruction until pending animations drain, so the hit that earned the kill lands before anything dies. The server still owns every outcome; the client only owns how it feels.
SECTION
Persistence at the right layer
EF Core and Postgres handle run history, player identity, corpses, and aggregate world stats. Configuration is environment-driven through `ConnectionStrings__DefaultConnection` and friends, and if no database is present in dev, every persistence service falls back to a Null implementation and the game still runs. Postgres is optional in development and required in production.
The server and Postgres ship as a multi-stage Dockerfile and compose stack. The Vite client runs separately and proxies `/game` to the server, so only one port is exposed to the LAN. Getting it running on another machine is three commands: `cp .env.example .env` to provide the Postgres password the compose file refuses to default, `docker compose up --build` for the server and Postgres, and `npm install && npm run dev` for the client.
OUTCOME
Shipped and ran at crawlers.brac.dev until October 2026, when the host was reassigned to another project. The source is on GitHub and runs locally with the three commands above. What shipped: single-player core, visual polish, combat juice, real-time co-op for 1-4 players with code-based lobbies and shared fog of war, and a persistent world. Across all 17 commits in the project's history, zero files were deleted or renamed, and the Domain layer deleted only 17 lines total across five expansions, growing almost entirely by addition.
EXHIBITS
Captures
CAPTURE / 01 OF 03

CAPTURE / 02 OF 03

CAPTURE / 03 OF 03

