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 by enforcing a strict boundary: the simulation knows nothing about the outside world. It receives commands, updates state, and emits events. That's it.
This concept was formalized by Brian Cronin — a game engineer who worked on Monster Train, Inkbound, and Natural Selection 2 — in his talk "Black Box Sim for Roguelikes" at Roguelike Celebration 2024.
I've been using a similar approach in projects I've worked on before and after I discovered this talk: Neoproxima, Manitou Lift and Drift, other unreleased prototypes.
This article describes a reference implementation and what this architecture unlocks.
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:
- Input is determined outside the sim (keyboard, touch, network, AI, script...)
- Input generates WorldCommands (outside sim)
- WorldCommands modify the sim, which produces a list of WorldEvents (inside sim)
- 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.cspublic 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 — useful for catching bugs early during development and makes clear in-code developer documentation about what a Command expects as existing states or parameters.
Side note: In production AssertApplicationConditions won't run and DoApply is executed at full speed with no guard. The strategy is that all bugs and misuse should be caught during dev and tests, and runtime should run as fast as possible. This is orthogonal to the pattern, other strategies can work.
Here's a concrete command — buying an item from the shop:
BuyFromShopWorldCommand.cspublic 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. It also 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:
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
var lootList = RuneDungeon.GenerateLoot(gameState, changeList);
}
}
In this command the combat resolves completely. Game logic code mutates the state and logs what happened (damage dealt, effects applied, deaths) into the changeList. changeList is sent to the presentation layer so that 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 there should be no persistent internal state. DoApply should be a pure function to allow testability, so every internal call should be a pure function as well.
In BuyFromShopWorldCommand logic is directly inside the command. Here it's too complex: splitting makes it both more readable, unit-testable, re-usable in other commands.
This is the black box in action: the sim computes the outcome, the client makes it look good.
Alternative: observable state
An alternative to returned WorldEvents is observable state — wrapping state fields in reactive containers (e.g. Signal<T> from TinkStateSharp) so the UI subscribes to values and updates automatically. Neoproxima uses this approach combined with an EventBus for side effects, and it's covered in detail in my article on reactive UI animations. Here's the trade-off.
With observable state, the UI binds directly to state values. This is convenient — you don't need to write a WorldEvent for every state change to display. But observables only expose the current value. If you batch-simulate 100 turns, you only see the final state. Intermediate steps are lost unless you add extra machinery to capture them.
With returned WorldEvents, every change is an explicit, ordered event in a list. You can batch-simulate an entire game, collect all events, and replay them one by one. This forces explicit handling of temporal evolution and intermediate states. It also means the sim has no dependency on an observable system or pub/sub infrastructure. The trade-off: you must describe every state change as a discrete event, which is more verbose. Displaying an arbitrary saved state also requires dedicated reconstruction logic (replaying events from the start), whereas with observables you can simply 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 temporal sequence — combat animations, chained effects, turn-by-turn replays. Observable state works well when only the current value matters most of the time. The two approaches can coexist within the same project. Neoproxima is mainly based on observables but some animated sequences selectively 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 engine is the same code. The server receives commands from clients over the network, processes them through the sim, and sends back events. No rewrite, no logic duplication, no divergence risk.
The Black Box Sim isn't a new idea — it's a disciplined application of separation of concerns to game architecture. Brian Cronin's contribution is formalizing 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.