Dungeon — grid dungeon crawler
C++23 · DirectX 12 · HLSL · XAudio2
An old-school grid-based dungeon crawler in the tradition of Dungeon Master and Legend of Grimrock, built on a custom C++23 engine and a from-scratch DirectX 12 renderer.
Overview
Dungeon is a first-person, grid-stepping crawler where the party moves cell by cell through a torch-lit dungeon. It pairs a deliberately retro movement model with a modern rendering pipeline — physically based shading, dynamic shadow-casting lights, volumetric dust and parallax-mapped stonework.
The whole thing is hand-written C++23 with no game-engine dependency. The renderer talks directly to Direct3D 12; audio runs on XAudio2; models, textures and sounds are produced offline by a companion asset baker. The codebase is split into strictly layered static libraries so that dependencies only ever flow one way.
Highlights
Custom DirectX 12 renderer
A forward renderer with metallic-roughness PBR, a derivative-based tangent frame (meshes carry no tangent attributes), and bump + parallax mapping driven by normal-in-RGB / height-in-alpha companion maps.
Dynamic lights & cube shadows
Torches are real point lights that cast omnidirectional shadows through cube distance maps. Shadow-map resolution falls off with distance from the camera, and per-cell dust scattering gives the light volume.
Strictly layered architecture
Nine static-library modules — Core, Platform, Assets, Animation, Graphics, UI, Audio, Game, Main — with one-directional dependencies. Engine modules know nothing about dungeons; all gameplay lives in Game; Main is glue only.
Zero-allocation steady state
Steady-state frames perform no heap allocation: GPU transient data comes from a per-frame bump allocator over a persistently mapped upload buffer, audio reuses a pool of source voices, and per-frame containers retain their capacity.
Offline asset pipeline
A command-line AssetBaker imports scanned PBR texture sets (Poly Haven, ambientCG, Megascans), packs ORM + height channels, BC7 block-compresses to pre-mipped DDS, and emits glTF block/prop/monster models the game loads at startup.
Data-driven content
Levels are plain ASCII maps under assets/maps — edit and relaunch, no rebuild. Quality tiers (Low/Medium/High/Ultra) swap mesh tessellation and 1K–4K texture resolution live, persisted to settings.ini.
Technical diary
The decisions behind the build — what each one cost, and what it bought.
Monster AI on its own threads
Thinking and acting are separate problems. A monster's decision — where to go, whether to engage — is pure computation over an immutable snapshot of the world, so it runs on worker threads; its movement stays on the main thread, serial and authoritative. Monsters are bucketed by intelligence, so a dim monster's change of mind lags while its sword arm does not.
Read the entry →A frame that allocates nothing
Steady-state frames perform no heap allocation — but not by one technique applied everywhere. Five different patterns, each picked for what its subsystem actually does: a linear arena for GPU transients, an object pool for audio voices, flat grids for AI, a free list for descriptors, retained capacity for per-frame containers. Load-time code deliberately opts out.
Read the entry →Identity in code, numbers in data
The received wisdom is to data-drive everything and embed a scripting language. This went the other way: no Lua, a class per spell and per effect, with the catalogs demoted to numeric overrides. Data can tune, never redefine — and the load-time merge enforces it rather than trusting anyone to remember.
Read the entry →A save that stores only what changed
The obvious way to save a game is to serialize the world, which makes every save a redundant copy of the level — and makes the save the level. Instead the static layer is never written at all: a load rebuilds from the authored files, then applies a diff of what actually drifted. An entity nobody touched costs zero bytes.
Read the entry →Writing a BC7 encoder, thrice
Textures ship block-compressed for a quarter of the VRAM, and how well those 16 bytes are packed is entirely the encoder's problem. One mode smeared every material edge; two fixed it; four got another 7 dB. The third version's real change was a test that can fail — which proved three of my decisions wrong, talked me into a mode I had argued myself out of, and then talked me out of building a fifth.
Read the entry →Screenshots
1/7 A side passage lit by a single wall sconce. Light falls off with distance and the brickwork's relief comes from parallax mapping, not geometry. full size ↗
2/7 The hall's colonnade. Braziers sit outboard of the columns so the shafts are lit from the side and throw their shadows up the nave. full size ↗
3/7 A Skeleton Warrior — a bought, rigged model with its own animation set, lit by the torch the party carries. full size ↗
4/7 The Skeleton Spearman, silhouetted against the lit apse beyond. Reach-2 weapons let him strike from the second rank. full size ↗
5/7 A brazier casts an omnidirectional shadow through a cube distance map; the flame is a particle system with per-cell dust scattering around it. full size ↗
6/7 Marble in the apse. Every surface is a scanned PBR set, BC7 block-compressed to a quarter of the VRAM of RGBA8. full size ↗
7/7 The full frame: four-character party bar, movement pad, spell panel and message log around the 3D viewport. full size ↗
Tech
C++23 DirectX 12 HLSL XAudio2 cgltf / glTF 2.0 stb_image BC7 / DDS CMake + Ninja PBR Skeletal animation