INTRO
A solo dungeon crawler built to prove a thesis: a server-authoritative architecture from day one is not over-engineering, even when you're shipping single-player. By the time I added real-time co-op, it slotted in. No rewrite, no parallel codepath.
The technical scope was the point — procedural generation, D&D-adjacent combat resolution, real-time state sync, a persistent shared world. ~21k lines of C# and TypeScript across server and client, 161 server tests. It exists because portfolio projects too often optimize for screenshots instead of decisions worth defending.
SECTION
The architectural bet
Single-player games usually keep gameplay logic on the client — it's the path of least resistance. I went the other way. 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; the client never receives data the player shouldn't see. The React + Pixi.js client sends intent and renders state diffs over SignalR.
That decision compounded. When I added 1-4 player co-op, the gameplay layer barely changed — the same room broadcast that drove single-player now fanned out to multiple connections. Fog of war was already per-player. The animation pipeline already consumed structured combat events. Persistence (corpses, world stats) was already isolated. The shape of the system absorbed multiplayer instead of resisting it.
Lobby screen with 4-player room
/work/crawlers/lobby.png1200 × 675 PX
Per-player fog-of-war filtered server-side
/work/crawlers/fog-of-war.png1200 × 675 PX
SECTION
Procedural generation that's testable
Floors are generated by BSP partitioning, deterministic per seed. The Generation project is a pure function of (seed, config) — no I/O, no DI, no hidden state. That's what made the system testable end-to-end: 161 server tests covering BSP layout, field-of-view, movement legality, engagement, combat, item interactions, and descent.
Combat uses a `ScriptedDice` test double, so non-deterministic outcomes are deterministic on demand. A flaky test is a real bug, not noise. And the same seed produces the same dungeon every time, which means bug reports are reproducible and runs are shareable.
ASCII renderer showing BSP-partitioned floor
/work/crawlers/bsp-floor.png1400 × 800 PX
SECTION
Combat as event stream, not state diff
Combat could have been the boring thing — server changes HP, client re-renders. Instead it emits a structured event stream: Hit, Crit, Miss, Fumble, Heal, each carrying target, magnitude, and timing. The client consumes this stream as an animation queue.
Hits get a lunge plus a red flash. Crits add camera shake. Misses sidestep. Heals pulse green. Killing-blow sprites defer destruction until pending animations drain, so death looks final because the animation that earned the kill finishes first. The result: combat *feel* lives entirely in the client, combat *truth* never leaves the server. Two concerns, cleanly separated.
Combat sequence with lunge, red flash, and camera shake on crit
/work/crawlers/combat.mp41280 × 720 PX
SECTION
Persistence at the right layer
EF Core + Postgres for run history, player identity, corpses, and aggregate world stats. Configuration is environment-driven (`ConnectionStrings__DefaultConnection` and friends) — if no DB is present in dev, every persistence service falls back to a Null implementation and the game still runs. Postgres is optional in dev, required in prod.
The whole thing ships as a multi-stage Dockerfile + compose stack: server, Postgres, and the Vite client behind a single LAN-bound port. From clone to playable on another machine takes one command.
OUTCOME
Shipped. Single-player core, visual polish, combat juice, real-time multiplayer (1-4 players, code-based lobbies, shared fog of war), and a persistent world all live at crawlers.brac.dev. Every expansion was absorbed by the original architecture — which was the whole point.
EXHIBITS
Captures
CAPTURE / 01 OF 03
Spectator mode with cross-floor camera follow
/work/crawlers/gallery-01.png1200 × 900 PX
CAPTURE / 02 OF 03
Run summary screen showing dice rolls and combat log
/work/crawlers/gallery-02.png1200 × 900 PX
CAPTURE / 03 OF 03
Mobile touch controls with D-pad and combat buttons
/work/crawlers/gallery-03.png1200 × 900 PX