Samuel Bouchet
Game developer — C#, TypeScript, shaders, game design

The Black Box Sim

08/04/2026

A game architecture that separates simulation from presentation. Commands in, events out, everything else stays outside.

The Black Box Sim flow: commands in, events out. The Black Box Sim flow: commands in, events out.

Most game projects end up with simulation logic tangled into rendering, input handling, and animation code. When your combat resolution calls the particle system, or your shop logic triggers a UI tween, you've built something that only works inside a running game client. It's hard to test, hard to port, and hard to reason about.

The Black Box Sim is an architecture that fixes this with a strict boundary: the simulation knows nothing about the outside world. It receives commands, updates state, and emits events.

Brian Cronin, a game engineer who worked on Monster Train, Inkbound and Natural Selection 2, formalized this concept in his talk "Black Box Sim for Roguelikes" at Roguelike Celebration 2024.

I used a similar approach before and after discovering this talk, on Neoproxima, Manitou Lift and Drift and some unreleased prototypes.

This article shows a reference implementation and what the architecture makes possible.

The pattern

The sim is a black box. The only way to modify state is through WorldCommands. The only way to know what happened is through WorldEvents.

There is no concept of keyboard, mouse, or controller input inside the sim. There is no concept of graphics, animation, or even time.

Here's the flow:

  1. Input is determined outside the sim (keyboard, touch, network, AI, script...)
  2. Input generates WorldCommands (outside sim)
  3. WorldCommands modify the sim, which produces a list of WorldEvents (inside sim)
  4. WorldEvents are processed outside the sim, driving animations, sound, and graphics over time

The sim processes a command instantly. All pacing, animation queuing, and time management belong to the presentation layer.

A reference implementation

The following examples are from The Rune Maker, an autobattler roguelike I'm currently working on, built in C# with Unity. The engine project (Runemaker.Engine) is pure C# and has zero Unity dependencies.

Commands

A command represents a player action. It validates preconditions and mutates state.

IWorldCommand.cs
public interface IWorldCommand {
    Guid GetId();
    void Apply(GameState gameState, List<WorldEvent> changeList);
    void AssertApplicationConditions(in GameState gameState);
}

public abstract class WorldCommand : IWorldCommand {

    private readonly Guid _id = Guid.NewGuid();
    public Guid GetId() => _id;

    public void Apply(GameState gameState, List<WorldEvent> changeList) {
#if DEBUG
        AssertApplicationConditions(gameState);
#endif
        // GameState accepts a lambda rather than IWorldCommand to stay decoupled (dependency inversion). Useful in tests.
        gameState.ApplyCommand(DoApply, changeList);
    }

    protected abstract void DoApply(GameState gameState, List<WorldEvent> changeList);
    public abstract void AssertApplicationConditions(in GameState gameState);
}

Apply() mutates the game state. AssertApplicationConditions() is a guard that throws if the command isn't valid in the current state. It catches bugs early during development, and it documents in code which state and parameters the command expects.

Side note: in production, AssertApplicationConditions doesn't run and DoApply is executed at full speed with no guard. The idea is to catch all bugs and misuse during development and tests, so that the runtime can be as fast as possible. This choice is independent of the pattern, other strategies work too.

Here's a concrete command — buying an item from the shop:

BuyFromShopWorldCommand.cs
public class BuyFromShopWorldCommand : WorldCommand {
    private readonly string _slotId;

    public BuyFromShopWorldCommand(string slotId) {
        _slotId = slotId;
    }

    protected override void DoApply(GameState gameState, List<WorldEvent> changeList) {
        var shopSlot = StateAccessor.FindSlot(gameState.CurrentRun, _slotId);
        // null checked in AssertApplicationConditions. Only keep the happy path here.
        var runeDef = shopSlot!.Rune;
        gameState.CurrentRun.Gold -= runeDef.Price;
        var benchSlot = StateAccessor.FindOrCreateAvailableSlot(gameState.CurrentRun);
        benchSlot.Rune = runeDef;
        shopSlot.Rune = null;

        changeList.Add(new GoldSpentChange(spent: runeDef.Price, finalValue: gameState.CurrentRun.Gold));
        changeList.Add(new RuneSlotMoveChange(shopSlot.Id, benchSlot.Id, runeDef));
    }

    public override void AssertApplicationConditions(in GameState gameState) {
        var shopSlot = StateAccessor.FindSlot(gameState.CurrentRun, _slotId);
        var runeDef = shopSlot?.Rune;
        if (runeDef == null)
            throw new ApplicationException("Slot has no Rune. Call BuyFromShopWorldCommand using a non-empty slot.");
        if (gameState.CurrentRun.Gold < runeDef.Price)
            throw new ApplicationException("Not enough gold. Ensure the player has enough gold before using BuyFromShopWorldCommand.");
    }
}

The command validates, calculates, mutates, and tells what changed.

State

State is a plain data structure, which makes it easy to serialize.

GameState.cs (simplified)
public class GameState {
    public readonly RunState CurrentRun = new();

    public void ApplyCommand(Action<GameState, List<WorldEvent>> apply, List<WorldEvent> changeList) {
        lock (_lockObject) {
            if (_isApplyingEvent) throw new ApplicationException("Nested command not allowed.");
            _isApplyingEvent = true;
            try {
                apply(this, changeList);
            } finally {
                _isApplyingEvent = false;
            }
        }
    }
}

public class RunState {
    public int Lives = 3;
    public int Gold = 120;
    public Phase CurrentPhase = Phase.None;
    public RuneGraph RuneGraph = new(new());
    public List<RuneSlotState> Bench = new();
    public List<ShopSlotState> Shop = new();
    public CombatState CombatState = new();
    public List<Loot> LootBox = new();
    // ...
}

The simulation loop

The most interesting command is StartCombatWorldCommand. Because the game is an autobattler, it runs an entire combat encounter synchronously inside a single command:

StartCombatWorldCommand.cs
public class StartCombatWorldCommand : WorldCommand {
    protected override void DoApply(GameState gameState, List<WorldEvent> changeList) {
        // RuneDungeon is responsible for dungeon content
        var currentRun = RuneDungeon.InitNextEncounter(gameState, changeList);

        currentRun.CombatState.CombatProgression = CombatStep.InProgress;
        while (currentRun.CombatState.CombatProgression == CombatStep.InProgress) {
            // RuneCombatSystem is responsible for combats logic
            RuneCombatSystem.Tick(currentRun, changeList);
        }

        // Generate loot after combat, this is dungeon content so it's RuneDungeon again
        RuneDungeon.GenerateLoot(gameState, changeList);
    }
}

In this command the combat resolves completely. Game logic mutates the state and logs what happened (damage dealt, effects applied, deaths) into the changeList, which is then sent to the presentation layer so it can replay it as a sequence of animations synced to music and visual effects.

Also note that game logic classes like RuneDungeon are static, because they should hold no persistent state. For testability, the result of DoApply should only depend on the game state and the command parameters, so every call inside it follows the same rule.

In BuyFromShopWorldCommand, the logic sits directly inside the command. Combat is too complex for that, and splitting it into systems makes it more readable, unit-testable and reusable in other commands.

This is the black box in action: the sim computes the outcome, and the client makes it look good.

Alternative: observable state

An alternative to returned WorldEvents is observable state: state fields live in reactive containers (e.g. State<T> from TinkStateSharp), and the UI subscribes to them and updates automatically. Neoproxima uses this approach with an EventBus for side effects. My article on reactive UI animations covers it in detail.

With observable state, the UI binds directly to state values. This is convenient, since you don't need to write a WorldEvent for every state change you want to display. But observables only expose the current value: if you batch-simulate 100 turns, you only see the final state, and intermediate steps are lost unless you add extra machinery to capture them.

With returned WorldEvents, every change is an explicit event in an ordered list. You can batch-simulate an entire game, collect all events, and replay them one by one. This forces explicit handling of intermediate states over time, and the sim doesn't depend on any observable system or pub/sub infrastructure. The trade-off is verbosity, since every state change must be described as a discrete event. Displaying an arbitrary saved state also requires dedicated reconstruction logic (replaying events from the start), while with observables you can load a serialized state snapshot and the bound UI updates directly.

In practice, WorldEvents are the right choice when the UI needs to replay a sequence over time: combat animations, chained effects, turn-by-turn replays. Observable state works well when the current value is what matters most of the time. Both approaches can coexist in the same project: Neoproxima is mainly based on observables, and some animated sequences use a changeList.

What black box sim enables

Isolated testing

Since the engine has zero dependencies on Unity, Godot, or any rendering framework, you can create a standalone test project that exercises all game logic headlessly. Instantiate a GameState, apply a sequence of commands, assert on the result. Tests run in milliseconds.

In a future article, I'll cover differential snapshot testing — a technique that makes regression testing of game logic nearly effortless by comparing serialized state snapshots across code changes.

Multiple clients

The sim speaks commands and events. Plug in any frontend:

  • A Unity client with full 3D rendering, animations, and sound
  • A web client using the same engine compiled to WebAssembly
  • A CLI client that takes text input and prints state — useful for scripted test scenarios (partial or complete playthroughs to validate level completion), a basic AI to test difficulty or outcomes across different situations, or even an LLM that actually plays the game

All of these share the exact same engine code. Only the input-to-command translation and event-to-presentation layers differ.

Server reuse

For networked games, the server needs to run the same game logic as the client. With a black box sim, that's already done: the server runs the same engine code. It receives commands from clients over the network, processes them through the sim, and sends back events. The logic exists only once, so client and server can't diverge.


The Black Box Sim is a disciplined application of separation of concerns to game architecture. Brian Cronin's contribution is to formalize it into a clear, repeatable pattern with practical guidance. If you're starting a new game project, especially a turn-based or simulation-heavy one, consider this architecture early.

Watch Brian's full talk: Black Box Sim for Roguelikes — Roguelike Celebration 2024.

Published by Samuel Bouchet.
Do you like reading SF? Try out latest game Neoproxima