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

Deterministic Chance

Games need dice, and dice are a problem for a pure language: a function that returns a different number each call is not a function. Mar offers two honest solutions, and choosing between them is easy once you see what each is for.

The Random module: chance as a command

Random is the app-style tool. You describe what kind of value you want with a Generator, and ask the runtime to produce one as a command; the result arrives as a message:

shapeGen : Random.Generator ColoredShape
shapeGen =
    Random.uniform (Circle Pink)
        [ Circle Orange, Square Purple, Square Blue ]


roundGen : Random.Generator Round
roundGen =
    Random.map2 Round shapeGen shapeGen


update msg model =
    case msg of
        Morph ->
            ( model, Random.generate Spawned roundGen )

        Spawned round ->
            ( { model | left = round.left, right = round.right }, Cmd.none )

This is exactly how the Touch-me-When example deals a new pair of shapes each second, and for that cadence it is perfect: an occasional roll, requested on an event, delivered next message. Use Random for title-screen shuffles, loot on a chest opened, “pick a random tip”.

For the sixty-times-a-second heart of a game, it has the wrong shape: the result arrives one message later, not inside step where the spawning logic lives, and the runtime owns the seed, so a run is not reproducible from your Model alone.

The in-model generator: chance as state

Every action game in the examples folder instead carries its own generator, five lines you met in chapter 2:

rnd : Int -> Int -> (Int, Int)
rnd seed n =
    let
        s2 = modI (seed * 16807) 2147483647
    in
    ( s2, modI (s2 // 64) (imax 1 n) )

This is the Park-Miller linear congruential generator: multiply the seed by 16807 modulo 2³¹−1, forever. Not cryptography, not statistics-grade; a completely respectable arcade die, the same family the consoles of the 80s used. Because it is plain integer math, it runs inside step, synchronously, and the current seed is a field of your Model like any other fact about the world.

The one discipline: every call consumes the seed and yields the next one, and you must thread it. Chained rolls look like this (from Star Catch’s spawner):

let
    (s2, rx) = rnd model.seed (model.w - 40)
    (s3, rv) = rnd s2 4
    star = { x = 20 + rx, y = 0 - 20, spd = 3 + rv }
in
{ model | stars = List.concat [ model.stars, [ star ] ], seed = s3 }

Reuse model.seed twice and both rolls come up identical, every time; forget to store s3 and next tick repeats this tick’s luck. The compiler cannot catch this one (both are well-typed), so it lives in Appendix B. The habit that prevents it: the last seed out always goes into the returned model, and never use a seed variable twice.

Helpers compose on top exactly as you would hope. A range roll is lo + rv; a percentage check is rnd seed 100 compared against a threshold; “pick one of n lanes, but not the last one used” is a roll plus a nudge. The card game builds shuffling on the same five lines, in a module shared by server and client.

Seeds are save files

Here is where the discipline pays out. The entire future of a run is determined by the starting seed plus the player’s inputs. That one sentence contains four features:

  • Replays. Record the input messages (a few bytes each); replay them into update from the same seed and the identical run unfolds, to the pixel, on any platform. No video, no divergence.
  • Daily runs. Seed from the date and every player in the world faces the same board. Two lines: fetch Time.now outside the simulation (at init or on Start), reduce it to a day number, store it as the seed.
  • Fair rematches. The card game’s “same deck, swap seats” rematch is literally “same seed, swap who goes first”.
  • Reproducible bugs. A tester’s “it crashed on wave 7” comes with the seed in the Model; you now have the crash in a test harness, forever.

Variety, when you want it, is also explicit: mix player timing into the pot at a moment that does not matter for fairness, such as seeding from the tick count on which they pressed Start. One tap of human jitter is worth thirty-one bits of entropy, and the run is still fully recorded by its (seed, inputs) pair.

Which one, then

  • Reach for Random when chance is an event: occasional, message-shaped, fine to arrive next frame.
  • Reach for the in-model LCG when chance is simulation: spawns, crits, drops, shuffles, anything inside the tick, anything you may ever want to replay.

Most games use both, and they do not conflict; they are two doors into the same Model. What matters is that in either case chance is data in the open, not a hidden global, and your game remains what this whole part of the book has been building: a pure function of seed and input, run very fast.