← BB / INDEX
CASE STUDY·2026·ARCHIVEDSOLO. DESIGN, ARCHITECTURE, ENGINEERING.

Crawlers

Server-authoritative dungeon crawler. Procedural floors, D&D-adjacent combat, real-time multiplayer.

STACK

  • C#
  • ASP.NET Core
  • SignalR
  • Postgres
  • React
  • Pixi.js
Crawlers in-game wide shot
Floor 1, dungeon view with the combat log open.

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.

01

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.

Lobby screen with 4-player room
Code-based lobbies, 1-4 players.
Per-player fog-of-war filtered server-side
Fog of war shades a tile grid the server always sends in full; visibility is tracked per floor and applied client-side.
02

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.

ASCII renderer showing BSP-partitioned floor
Server-side ASCII renderer. Same seed, same floor, every time.
03

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.

Crit → camera shake. Killing blow defers sprite destruction until anims drain.
04

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.

05

EXHIBITS

CAPTURE / 01 OF 03

Spectator mode with cross-floor camera follow
Spectator mode: camera follows a teammate after death.

CAPTURE / 02 OF 03

Run summary screen showing deepest floor, enemies slain, and cause of death per player
Run summary: deepest floor, enemies slain, run length, and per-player cause of death.

CAPTURE / 03 OF 03

Mobile touch controls with a D-pad
Touch controls auto-appear on coarse pointers.