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, it slotted in with no rewrite and no parallel codepath.
The scope was deliberately broad: procedural generation, D&D-adjacent combat resolution, real-time state sync, and a persistent shared world, backed by 161 server tests. I wanted a project whose decisions I could defend, not just screenshot.
SECTION
The architectural bet
Single-player games usually keep gameplay logic on the client because it's the least work. I put it on the server. The ASP.NET Core server owns every truth: movement legality, line-of-sight, dice rolls, item drops, even which tiles each player is allowed to see. Fog of war is filtered server-side, so the client never receives data the player shouldn't have. The React and Pixi.js client sends intent and renders state diffs over SignalR.
That paid off when co-op arrived. The same room broadcast that drove single-player now fanned out to several connections, and the gameplay layer barely changed. 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. Multiplayer mostly dropped into gaps the design had left for it.


SECTION
Procedural generation that's testable
Floors come from BSP partitioning, deterministic per seed. The Generation project is a pure function of (seed, config) with no I/O, no DI, and no hidden state. That's what let 161 server tests 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, bug reports reproduce exactly and runs are shareable.

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 whole thing ships as a multi-stage Dockerfile and compose stack: server, Postgres, and the Vite client behind a single LAN-bound port. Clone to playable on another machine is one command.
OUTCOME
Live at crawlers.brac.dev: 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. Every one of those expansions fit the architecture the first version already had.
EXHIBITS
Captures
CAPTURE / 01 OF 03

CAPTURE / 02 OF 03

CAPTURE / 03 OF 03

