Games

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
Hollowdeep in landscape, a warrior in a torch-lit mossy cellar with health and mana bars, two rats at the right edge and the skill bar along the bottom

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

Hollowdeep layersContent JSON feeds Core, which holds the simulation and rules. Core feeds the Game layer. Balance bots, the save system and the tests all connect to Core.Content (JSON)Core: sim + rulesGame: UI ToolkitBalance botsSave: seed + logTests: Edit+Play

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;
        }
Loading a run replays its commands and checks the result against the saved hash. Scripts/Core/Run/RunState.cs

Architecture 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));
        }
The clock rule, as a test that scans every Core file. Tests/EditMode/ArchitectureTests.cs

Floors 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.