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

Tech

C++23 DirectX 12 HLSL XAudio2 cgltf / glTF 2.0 stb_image BC7 / DDS CMake + Ninja PBR Skeletal animation

← Back to all work