Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Shape of a Real Game

Star Catch fits in one file, and one file is the correct architecture until it is not. This chapter is about what changes between the 250-line demo and the 5000-line game: how shipped Mar games organize code, model their phases, script their setpieces, and test themselves, plus the two structural traps that only appear at scale.

One file, then three

Stay in a single Main.mar as long as scrolling does not hurt; several finished examples never leave it. When you split, the pattern the larger games converge on is small and boring:

  • Types.mar: the Model, the Msg, the core records, the constants, and the pure helpers everyone needs (imax, modI, rnd). The RTS keeps its LCG here.
  • Data modules: levels, dialogue, bestiary, card lists: big honest values, one module per kind. The adventure-port generates these from tooling; hand-written works the same.
  • Main.mar: the loop: update, view, subscriptions, wiring.

Modules are files; import Types exposing (..) pulls the vocabulary in. Resist inventing deeper structure than that. A game is one program with one Model; carving it into fifteen “systems” modules mostly manufactures import ceremony.

Phases are a union, and the union is the spec

Every game is a state machine at the top:

type Phase
    = Title
    | Briefing Int          -- which mission's text is showing
    | Playing
    | Dying Int             -- countdown to the game-over screen
    | Over Outcome

Two structural choices, both fine, chosen per game:

  • Flat Model plus a phase tag (most examples): every field lives in the Model at all times; phases say which ones matter. Simple, hot-reload-friendly, mild discipline required (the score exists on the title screen; nobody looks at it).
  • Payload phases (Briefing Int, Over Outcome): put phase-specific data in the constructor. The compiler then guarantees a mission number exists exactly when briefing, which is worth having wherever a bare flag would need a comment.

Whichever you choose, the dividend is the same: case model.phase of in update, view, and subscriptions is checked for exhaustiveness. Adding a Shop phase and following the compile errors is the implementation plan.

Setpieces are timers, not scripts

Bosses, cutscenes, and wave intros need sequenced behavior over time, and there is no coroutine to “wait 2 seconds” in. The idiom, used by every boss in the gallery: a phase field on the actor plus a countdown, stepped by the tick.

stepBoss : Boss -> Boss
stepBoss boss =
    let
        t = boss.t - 1
    in
    if t > 0 then
        { boss | t = t }
    else
        case boss.act of
            Gliding  -> { boss | act = Winding, t = 42 }    -- 700 ms telegraph
            Winding  -> { boss | act = Charging, t = 30 }
            Charging -> { boss | act = Gliding, t = 90 }

A cutscene is the same machine at page scale: a list of steps in the Model, a timer, advance on expiry or on tap. It looks unglamorous next to a scripting DSL and it debugs like a dream: the entire “script position” is two fields you can see in the time-travel panel.

Two traps that arrive with size

Top-level values evaluate in file order. A top-level value (a palette color, a level list, a precomputed table) is evaluated eagerly, top to bottom. If it refers to a name defined below it in the file, the program can compile and still crash at startup with a unit value where data should be. Functions are immune (they run later); values are not. The rule: constants and data flow strictly downward: palette, then tables built from the palette, then things built from tables. When a big data module misbehaves at boot, look for a forward reference first.

A fold that computes and drops. Described fully in chapter 9’s battle section, restated here because it is a scale trap: it only appears once several systems interact in one pass. Any value computed inside a fold’s branches must leave through the accumulator, or it did not happen. The RTS shipped a real bug this way (damage computed mid-fold, discarded, shots working only on their first tick). When battle math is mysteriously weak, audit the folds.

Tuning lives at the top

Every number a designer might touch goes in one named block near the top of Types.mar or Main.mar: speeds, costs, timers, telegraph durations, spawn tables. Two reasons beyond tidiness. Hot reload turns each constant into a live slider: change gravity mid-jump and feel it. And numbers with names document intent (coyoteTicks = 5) where a 5 in a formula documents nothing.

Your game is a library with a loop on top

The quiet consequence of everything above: because step and its helpers are pure functions over plain data, they do not need the canvas, the browser, or the app shell to run. The rhythm runner exploits this by running a bot through its real physics in a script to prove every level is beatable before shipping; the card game’s rules module is exercised by the server, the client, and its tests identically. When you structure a game as “a pure rules library plus a thin MVU rind”, testing stops being a game-dev luxury and becomes ordinary unit testing, and chapter 17’s multiplayer falls out of the same property.

Next, the chapter where we count milliseconds.