Workstation4 / Blog / architecture
architecture godot game-design testing

Why the game runs headless before it ever draws a pixel.

One early decision in Life turned out to matter more than I expected: the simulation is not allowed to know that a screen exists. The board, the animated cars driving along a branched path, the leaderboard, the five picture-in-picture follow cameras, none of that is in scope when a round resolves. The rules live in their own folder, life/sim/, and the very first line of that folder's notes to myself reads: "The simulation knows nothing about UI nodes. It can run headless." Holding that line shaped almost everything that came after.

A game with no screen

Life is a broadcast game. The idea is that an audience plays together by typing chat commands while a stream renders the action. It is really tempting to start drawing right away: cars, a board, motion, all the satisfying stuff. I went the other way. The whole game hangs off a single GameSession, a plain RefCounted object that owns the lifecycle and never touches a node in the scene tree.

That session late-binds a set of services, each of which is logic only. A PlayerRegistry holds the players. A CommandProcessor parses chat lines into actions. A RoundManager runs the phase machine. Then there are services for spins, the economy, the leaderboard, penalties, bots, tile effects, and a PathGraph that walks the board's branched topology. They mutate state, they answer questions, and they emit signals. None of them ever reach up into the renderer. The presentation layer, when it exists, binds to that same session from the outside and listens for phase changes to toggle its overlays. As I wrote it for myself: the presentation layer reads from here and listens to its signals; it does not mutate game state directly.

The phase machine

Rounds advance through a small state machine, an enum Phase on the session. The cycle is easy to hold in your head:

IDLE → SETUP_OPEN → SETUP_CLOSING → ROUND_INPUT_OPEN
  ↓                                       ↓
GAME_COMPLETE ← ROUND_SUMMARY ← ROUND_RESOLVING

A round opens an input window, collects spins, resolves them, shows a summary, then loops. The only thing that moves the machine forward is a tick() on RoundManager, called once per frame, which reads session.seconds_remaining() and the submission counts and decides whether to advance. Critically, the round manager is purely about pacing. It does not know how a spin turns into board movement; the scene hands it an apply_spin_callable that takes a player and a spin value and returns a dictionary of what happened. So the engine can resolve a round whether the result is a car animating across a 2190×1700 board or nothing at all.

Because the source of spins is just "whoever submitted this round," the machine cannot tell who is playing. Today the players are bots; later they are live chatters. The phase machine treats them identically, and that turned out to be the useful part.

One signal to go live

Chat does not arrive directly into the game. It arrives through an abstract ChatSource whose entire contract is one signal:

signal chat_received(user_id: String, display_name: String, message: String)

# Bind a CommandProcessor so chat fires straight into gameplay.
func bind_to(proc) -> void:
    chat_received.connect(
        func(uid, name, msg): proc.handle(uid, name, msg))

A session always has exactly one ChatSource. There are two concrete subclasses. DebugChatSource is input-driven: a textbox in the debug panel, plus a bulk-paste mode for stress testing on a single machine with no external service. TwitchChatSource is a WebSocket IRC client pointed at irc-ws.chat.twitch.tv; it polls its socket every frame to drain PRIVMSG packets and reply to PING. Both emit the exact same three-argument signal, so the CommandProcessor consuming it has no idea which one is live.

The wire from chat to gameplay is genuinely one line in the bootstrap. Picking the source is a small branch: an explicit channel flag wins, then a saved channel from the operator's settings, then DebugChatSource for local work. Nothing in the rules, the round manager, or the renderer changes when that branch goes a different way, because none of them ever depended on the concrete source. The seam is the contract, and I tried hard to keep the contract small. One detail I am quietly fond of: the leading ! on a command is optional, so both spin and !spin work. user_id is stable across messages and becomes the PlayerState.id; display_name can change, which matters because Twitch users can rename themselves mid-stream.

Deterministic from a seed

Because the simulation is isolated, it can be made repeatable. The session owns a single RandomNumberGenerator and a seed_value, and every random draw flows through it: spin rolls, bot personalities, the per-fork branch preference each player carries through the path graph, even the randomized starting insurance that gives hazard tiles some variance. Seed it the same way, feed it the same inputs, and a run plays out identically. The command-line entry point parses --seed in a deliberate first pass, before --add-bots, specifically so the bot-spawning RNG draws happen against a known seed and the lineup is reproducible.

That turns testing from guesswork into something I can replay. A bug that only shows up on round forty of one particular game is no longer something you stumble into live; it is a seed you can re-run on demand until it is fixed.

If the rules cannot run without the renderer, then every test has to drag the renderer along, and the slow, real-time pixel layer becomes the thing you debug. I would much rather not debug a follow camera to find a money-rounding bug.

Autoplay and the smoke test

The payoff shows up in a tiny command-line path. Pass --add-bots=N --autoplay=M and the scene fills a lobby with bots and then rips through M rounds with no human and no waiting. Each frame the autoplay loop does two things in order: get every bot a chance to !spin first, then force the round closed.

if _autoplay_remaining > 0:
    if session.phase == GameSession.Phase.ROUND_INPUT_OPEN:
        # spin everyone FIRST so PenaltyService doesn't strike them out
        session.bots.spin_all_bots_now()
        session.rounds.force_resolve_now()
        _autoplay_remaining -= 1

The ordering is the gotcha I had to learn the hard way. Normal play has a deliberately slow input window, a 25-second timer with a minimum 8-second floor, so a real human in a bot-heavy lobby has time to react before aggressive bots, who respond in about a second, close the round. Autoplay bypasses that floor with force_resolve_now() so smoke tests still finish fast. But if you force the round closed before the bots have spun, PenaltyService.auto_spin_missing() treats them all as no-shows and hands out strikes, which quietly poisons the run. Spin first, then resolve. That single ordering bug cost me an afternoon of confused leaderboards.

With that in place I can stand up a session, fill it with bots, and play several complete games to GAME_COMPLETE in well under a minute, checking that nothing throws and that net worth, life-tile counts, and ranks stay consistent. Real edge cases live in that path: the soft round cap that refuses to end the game while a slow walker is still on the board, the lobby that extends itself in 30-second increments until at least two players join, the all-retired check that ends a game early instead of waiting out empty rounds. Each is much easier to trust when I can watch a hundred headless games agree.

Why it was worth it

Building the rules first kept the messy real-time UI work from leaking into the logic. When the render layer arrived, it had nothing to prove about the game; it only had to display a game that already worked. The clearest sign of this is the persistent leaderboard: when a game completes, the session pushes the final standings into a high-score store, and it deliberately filters out bots, because their scores would clutter the human rosters. That rule lives in the sim, not the screen, exactly where it belongs.

The discipline does cost something up front. You write a game you cannot watch, and you trust your assertions instead of your eyes for a while. But the payoff has only grown with every new rule and feature.

  • Tests and repro runs finish in seconds because they never open a window.
  • Going live is picking a different ChatSource, not a rewrite; the chat_received signal is the only contract.
  • Any bug is reproducible from a seed, so it can be cornered and fixed instead of chased.
  • The render layer stays honest: it reads state and listens for signals, it never owns the rules.

Life is still in progress, but this foundation is the part I feel best about so far. The game knows how to play itself, quietly and correctly, long before anyone presses play. Everything I draw on top is just letting the audience watch.

W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →