Hollowdeep
A one-thumb, portrait dark-fantasy roguelike for Android, built on a deterministic simulation that tests guard and whose balance is measured by scripted bots.
- Role
- Founder · product owner · architect (built with an AI agent team)
- Timeline
- From 15 Oct 2026, ongoing
- Status
- Pre-release
- Unity 6 LTS
- C#
- UI Toolkit
- NUnit
- Python
- ComfyUI
- 343Commits in 11 days
- 39.5k vs 29.5kTest lines vs production lines
- 1,903Automated tests, last full run
- 240Bot runs per class behind each balance band

The problem
Hollowdeep is a short dark-fantasy roguelike for phones, played with one thumb in portrait, sold as a free demo plus one unlock, with no ads, energy timers or tracking. A permadeath game with random floors, random loot and a meta layer can betray the player in three ways: a floor that traps you, a saved run that comes back different, and an upgrade that leaves you weaker. I wanted each to be a property a test can fail, not something a playtest might catch.
What I built
A pre-release Android game in Unity 6 LTS: 2D pixel art, top-down, UI Toolkit. It has four classes, three worlds (Mossroot Cellars, Cinderhall Mines, the Drowned Hamlet) and six bosses with telegraphed attacks. A run is one world: four floors, a mid-boss after the second, then the world boss. Between runs you spend what you carry out in the Haven; a smith turns plans into gear, a peddler buys and sells, and a Codex records items and monsters met. The free demo covers the first world with two classes. The billing code is built, but the Play product does not exist yet, so the game is not on Google Play. Development builds run on a physical Android phone. The audio is synthesised in code, and the art is AI-generated (see the provenance note).
Architecture
Game depends on Core and Core never references Game. Bots and tests drive Core with no screen involved. Source paths below are in the private repo, under Hollowdeep/Assets/.
Engineering highlights
A deterministic simulation, and saves that are replays
Core decides outcomes with whole-number arithmetic and a tick counter. Randomness comes from eight named streams (map, spawn, AI, combat, loot, charm, proc, craft) derived from one run seed, so adding a monster cannot reshuffle the floor or the loot you would have found. It also lets a suspended run be stored as a floor-entry checkpoint plus the player’s commands: loading replays them and refuses the save unless the state hash matches. Evidence: Scripts/Core/Sim/RunRng.cs, Scripts/Core/Run/RunState.cs. GoldenTests freezes 20 floor-one layouts and every floor of two more seeds, and TheGoldenTablesAndTheSimVersionMoveTogether ties a rules change to a save-version bump.
// Hollowdeep/Assets/Scripts/Core/Run/RunState.cs (lines 596-614, one line removed)
/// <summary>Rebuild a suspended run. Throws if the replay does not land on the saved state hash (corrupt save or changed content).</summary>
public static RunState Load(ContentDb content, RunSave save, FloorGenParams genParams = null, bool linger = false)
{
var run = Rebuild(content, save, genParams, linger);
var commands = ExpandCommands(save.commands);
int next = 0;
for (int tick = 0; tick < save.ticksOnFloor; tick++)
{
while (next < commands.Count && commands[next].tick == tick) run.Replay(commands[next++]);
if (!run.Step()) break;
}
while (next < commands.Count) run.Replay(commands[next++]); // issued after the last tick, still waiting for the next one
if (run.Sim.StateHash().ToString(System.Globalization.CultureInfo.InvariantCulture) != save.stateHash)
throw new InvalidOperationException("the saved run does not replay to the saved state");
return run;
}Scripts/Core/Run/RunState.csArchitecture enforced by tests
Several agents edit one working tree, so a rule in a document gets broken and a rule in a test gets fixed. Nine EditMode tests in ArchitectureTests fail if Core references the Game assembly, defines a Unity object, reads the clock, calls unseeded random, decides with floating point or holds mutable static state. Core still compiles against Unity’s maths types, so “pure” means free of the game layer, not of Unity.
// Hollowdeep/Assets/Tests/EditMode/ArchitectureTests.cs (lines 100-113)
[Test]
public void CoreNeverReadsTheClockOrFrameTime()
{
// Simulation time is the tick counter. Wall-clock or frame time in Core would make runs differ between machines and replays.
var banned = new[] { "DateTime.Now", "DateTime.UtcNow", "Time.time", "Time.deltaTime", "Time.realtimeSinceStartup", "Time.fixedDeltaTime", "Environment.TickCount", "Stopwatch" };
var root = System.IO.Path.Combine(UnityEngine.Application.dataPath, "Scripts", "Core");
var offenders = System.IO.Directory.GetFiles(root, "*.cs", System.IO.SearchOption.AllDirectories)
.SelectMany(f => System.IO.File.ReadAllLines(f)
.Select((line, i) => (file: System.IO.Path.GetFileName(f), line: line.Trim(), no: i + 1))
.Where(x => !x.line.StartsWith("//") && banned.Any(b => x.line.Contains(b))))
.Select(x => $"{x.file}:{x.no}: {x.line}")
.ToList();
Assert.IsEmpty(offenders, "Core must be driven by ticks only:\n " + string.Join("\n ", offenders));
}Tests/EditMode/ArchitectureTests.csFloors that cannot trap you
Scripts/Core/Gen/FloorGenerator.cs sorts rooms left to right and joins each to the next with an L-shaped corridor, so connectivity holds by construction. It draws up to 12 layouts from the same stream until entry and stairs are far enough apart. Water, embers, mire and set pieces are flood-fill checked and undone if they would split the floor. FloorGeneratorTests asserts every walkable cell is reachable on 500 seeds.
Balance measured by bots, and a bug they found
Balance is a set of bands (deaths per act, boss conversion, completion) measured by scripted bots in Scripts/Core/Balance, 240 seeds per class, and gated by slow tests. The bots caught a bug: in Mossroot the Emberwright completed about 33% of runs at drop-odds level 0 but about 23% at level 2 with the cheapest trait. That broke the promise that spending in the Haven never makes a class weaker. Odds upgrades were starving armour; the fix, in simulation version 24, draws odds weight only from other classes’ weapons (oddsSpareWearables), leaves odds 0 unchanged (OddsSpareWearablesTests rolls 1,000 drops per monster and class) and adds NoClassCompletesLessForBuyingOdds: at 480 seeds per class, none may complete more than 3 points under its own odds-0 rate, in either world.
A bot is a floor for an average run, not a person. My first clear of the first two worlds took 11 min 9 s and I called it too easy. The band bot is the one that matched that clear; the two-act structure and a harder balance followed.
Process & quality
The suite was last recorded green at 1,636 EditMode and 267 PlayMode tests, and test C# (over 39,000 lines) outweighs production C# (over 29,000). Code commits wait for a green full suite, run in a separate clone, one Unity run at a time, behind a directory lock. The GitHub workflow has never passed, and it cannot until the Unity licence secrets are added, so I do not claim CI.
On 2026-10-24 three review agents with no stake in the work audited code, docs and QA. One finding: the autosave could delete a finished run’s slot before the result was banked, so a full disk plus a kill lost the run. Now RunEnded is the only deleter. The docs pass that followed made 129 edits across 18 documents. A phone pass showed 60 FPS and no memory leak over three Haven-run cycles, and caught an empty “Look ahead” sheet, fixed the same day.
Roles, plainly. Eight specialist agents (story, game design, art direction, tech art, gameplay, UI/UX, audio, QA) and a head-lead agent wrote the code, tests, art batches and reviews, one lane each, at most three code-writers at once. My part was product decisions, taste calls and playing the builds. I moved the camera from 3D low-poly to 2D pixel art, set the rule of no ads, timers or paywalled quality of life, chose to generate art without a pixel artist, and after that 11 min 9 s run set the world structure: two acts, six bosses, three worlds at launch.
What I’d do differently
- Set up CI first. All 1,903 tests run in a local clone, and an agent reports the green. I would add the Unity licence and a runner on day one so something else checks.
- Model a human player earlier. On the same data the bag-hoarding bot completed Mossroot about 43% of the time and the bag-managing bot about 73%. The bands moved to the second bot in simulation version 25, after tuning against the first.
- Version saves by content slice. A 2026-10-25 review found that buying a content pack would make every suspended run unresumable, with the wrong reason shown. No pack ships yet; it is still on my open list.
- Check store text against data. The listing draft still says two worlds; the game has three.
Source available on request.
Gallery

The default layout, portrait and one-handed. The combat log lists hits and damage. 
A status effect shown on the run screen, with the log saying where it came from. 
Bag, ground and worn equipment on one screen. 
The peddler buys and sells in coins earned in play. 
The Haven class screen, with its drop-odds panel. 
The Reliquary, a collection screen that fills as gear is found.