Files
functional-programming-snake/course/03-pure-game-rules.md
T
2026-10-29 00:59:58 +01:00

23 KiB
Raw Blame History

Module 3 — Pure game rules

Time: ~3–4 h · You'll edit: JS js/src/rules.js · Scala scala/src/Rules.scala · F# fsharp/src/Rules.fs Tests: JS cd js && node --test "test/m03-*.test.js" · Scala cd scala && ./test.sh M03 · F# cd fsharp && ./test.sh M03 Prerequisites: Modules 1 and 2

Why this module

In a stateful Snake, the rules live in methods like snake.move(), snake.changeDirection(d) and game.checkCollision(). They are called in some order from a game loop, and each one reads and writes shared fields. The order of those calls is part of the rules, but it is written nowhere. Is the collision checked before or after the tail moves? Before or after the snake grows? You find out by reading the loop, or by watching bugs.

In this module, every rule is a pure function from a state to a new state. turn(state, dir) returns the state after the player asks to turn. advance(state) returns the state one step later. Each function says, in one place, everything that happens in that step and in which order. You test it by handing it a state and comparing the result with the expected state; no timers, no setup, no mocks.

Two of the rules hide real subtleties that many Snake implementations get wrong. You will meet them as puzzles, and reason them through before you read the explanations.

Learning objectives

After this module you can:

  • implement game rules as total functions State → State using copy-with-changes;
  • explain why a rule returns the state unchanged instead of throwing when it does not apply;
  • reason about the order of events inside one step (move, grow, collide) and predict the result for a given board;
  • explain why turn must compare against dir (last moved) and not nextDir;
  • read a fixture case, draw it, and use it to debug.

Concepts

Rules as functions State → State

Each rule has the same shape: it takes the whole game state (plus maybe some input) and returns a whole new game state.

turn    : (GameState, Direction) -> GameState
advance :  GameState             -> GameState

Because the result is a full state, rules can be chained: advance(turn(s, 'Up')) is the state after turning up and moving. Because they are pure, a rule called with a frozen state from a fixture gives exactly the fixture's expected state, every time.

Inside a rule you use the "copy with changes" move from Module 1: { ...state, nextDir: dir } in JS, state.copy(nextDir = dir) in Scala, { state with NextDir = dir } in F#. You never assign to a field.

Total functions: "unchanged" is a valid answer

A function is total if it returns a result for every possible input. It never throws, never loops forever, never returns undefined by accident.

What should turn do when the game is paused? Throw "cannot turn while paused"? Then every caller must catch, and a key press during pause would crash the game. Instead, our rules follow one simple convention: when a rule does not apply, it returns the state unchanged. turn while paused returns the same state. advance when the game is over returns the same state. This is what the fixtures turn-ignored-when-paused, advance-game-over-unchanged, advance-won-unchanged check.

From Scala: this is the same reason you prefer headOption to head, and Option to exceptions: totality moves "what if it does not apply?" from a runtime surprise to an ordinary value. In a game, "nothing happens" is the right value surprisingly often.

A typical rule therefore starts with a guard:

JavaScript

// (illustration on a different record)
const heal = (hero, amount) => {
  if (hero.hp <= 0) return hero                   // dead heroes cannot be healed: unchanged
  return { ...hero, hp: Math.min(hero.maxHp, hero.hp + amount) }
}

Scala

def heal(hero: Hero, amount: Int): Hero =
  if hero.hp <= 0 then hero
  else hero.copy(hp = math.min(hero.maxHp, hero.hp + amount))

F#

let heal (hero: Hero) (amount: int) : Hero =
    if hero.Hp <= 0 then hero
    else { hero with Hp = min hero.MaxHp (hero.Hp + amount) }

Compute, decide, build

Longer rules read best in three phases:

  1. Compute the facts you need, each as a named const / val / let: where the head goes, whether it is eating, what the body looks like afterwards.
  2. Decide which outcome applies (a collision? a normal move?).
  3. Build the result with one copy-with-changes per outcome.

Named intermediate values cost nothing (they are not variables; they never change) and make the rule read like its specification. Compare the doc comment of advance in your stub with the code you write: they should look alike.

The four rules

  • move(p, dir): the cell one step from p in direction dir. It is p + delta(dir). It does not wrap around the edges: moving Right from x = 4 on a 5-wide board gives x = 5, off the board. Deciding what that means is another rule's job (fixture move-does-not-wrap-at-the-edge).
  • inBounds(state, p): is p on the board? 0 <= x < width and 0 <= y < height. Note the strict <: on a 5-wide board, x = 5 is outside (fixtures x-equals-width, y-equals-height).
  • turn(state, dir): the player asks to go in dir. Only while Playing, and never straight back the way the snake came. If allowed, it sets nextDir; it does not move anything.
  • advance(state): one step of time. The head moves one cell in nextDir (which then becomes dir). Then, depending on where it lands: it eats, or it moves, or it crashes.

move and inBounds are warm-ups. The interesting parts are in turn and advance. Now the puzzles.

Puzzle 1: can the snake bite its own tail?

Here is a 5 x 5 board. The snake has four segments, in a tight square. Head H at (1,1), then (1,2), (2,2), and the tail T at (2,1). It is about to move Right, onto the tail's cell. There is no apple.

        x=0  x=1  x=2  x=3  x=4
  y=0    .    .    .    .    .
  y=1    .    H    T    .    .        snake (head first): (1,1) (1,2) (2,2) (2,1)
  y=2    .    b    b    .    .        nextDir: Right
  y=3    .    .    .    .    .
  y=4    .    .    .    .    .

Before reading on, decide: is this move a collision (game over), or legal? Draw what the board looks like after the move in each case. Then consider a second board: identical, except that there is an apple on (2,1), the cell where the tail is. Same question.

Think about it first, then open the explanation

All segments of the snake move at the same time. In one step, the head enters a new cell and, if the snake is not eating, the tail leaves its cell. So in the first board, by the time the head arrives at (2,1), the tail has moved on. The move is legal, and the snake chases its own tail around the square:

  before: (1,1) (1,2) (2,2) (2,1)       after: (2,1) (1,1) (1,2) (2,2)

In the second board, the head lands on the apple, so the snake grows: the tail stays where it is. Now the head and the tail would occupy the same cell, which is a collision: game over.

So the right check is not "is the new head on the current snake?" but "is the new head on the body that remains after this step?" And the remaining body depends on whether the snake is eating:

eating?  yes -> body = the whole old snake       (tail stays, the snake grows)
         no  -> body = the old snake minus its last cell  (tail leaves)
collision if the new head is off the board, or is contained in body

This is why advance must first decide whether it eats, then compute the body, and only then check for collisions. The order is the rule. The fixtures advance-into-vacating-tail and advance-into-tail-while-eating are exactly these two boards. Many implementations check the collision against the old snake and wrongly kill the player in the first case.

Puzzle 2: the quick double turn

The snake moves one cell per tick (150 ms). The player can press several keys between two ticks. Suppose the snake is moving Right:

        x=0  x=1  x=2  x=3
  y=1    .    .    .    .
  y=2    b    b    H    .       dir = Right, nextDir = Right

Within one tick, the player quickly presses Up and then Left (perhaps aiming for a U-turn). The rule says "you may not turn straight back". Here are two ways to write that check:

version A:  reject dir if dir == opposite(state.nextDir)
version B:  reject dir if dir == opposite(state.dir)

Before reading on, trace both versions. Write down dir and nextDir after each key press, and then where the head goes on the next tick. Which version lets the player kill the snake with two key presses?

Think about it first, then open the explanation

Version A (check against nextDir):

  1. Press Up. Opposite of nextDir (Right) is Left; Up is fine. Now nextDir = Up. The snake has not moved: dir is still Right.
  2. Press Left. Opposite of nextDir (Up) is Down; Left is fine! Now nextDir = Left.
  3. Tick. The head moves Left, from (2,2) to (1,2), straight into its own neck. Game over, from two perfectly reasonable key presses.

The check compared against a direction the snake had not moved in yet. The snake's body still points the old way.

Version B (check against dir, the direction the snake last moved):

  1. Press Up. Opposite of dir (Right) is Left; Up is fine. nextDir = Up.
  2. Press Left. Opposite of dir (still Right, nothing has moved) is Left: rejected. The state is unchanged; nextDir stays Up.
  3. Tick. The head moves Up. Safe.

That is why the model has two directions (Module 2, question 3): dir is a fact about the body (where the neck is), nextDir is a wish. Turns are validated against the fact. The fixture turn-against-last-moved-dir is exactly this situation: dir = Right, nextDir = Up, turn Left, expected result: unchanged.

Notice the consequences of the rule: several allowed turns within one tick overwrite each other, and "the last allowed one wins" (fixture turn-replaces-pending-turn: with dir = Right and nextDir = Up, pressing Down gives nextDir = Down, which is allowed because Down is not the opposite of Right). And the player's U-turn intention (Up, then Left) is partly lost: only Up happens. Some games keep a small queue of pending turns to honour both; see the questions at the end.

What a crash looks like

When the head would leave the board or hit the remaining body, advance returns the state with:

  • status: 'GameOver',
  • dir set to the direction it tried to move in,
  • everything else, including the snake, unchanged.

The snake is not moved into the wall: the last picture the player sees is the snake just before the crash. Recording dir keeps a simple invariant: after any advance of a playing game, dir === nextDir. The fixture advance-into-tail-while-eating checks this: before the step dir is Up and nextDir is Right; the snake crashes, and the result has dir: 'Right' with the snake exactly as it was.

Reading a fixture

A fixture case is { name, args, expected }. For advance, args is a one-element list (the state) and expected is the resulting state. Here is the shape of advance-into-wall-is-game-over, trimmed:

{ "name": "advance-into-wall-is-game-over",
  "args": [ { "width": 5, "height": 5, "snake": [ {"x": 4, "y": 2}, {"x": 3, "y": 2}, {"x": 2, "y": 2} ],
              "dir": "Right", "nextDir": "Right", "apple": null, "status": "Playing", ... } ],
  "expected": { "width": 5, "height": 5, "snake": [ {"x": 4, "y": 2}, {"x": 3, "y": 2}, {"x": 2, "y": 2} ],
                "dir": "Right", "nextDir": "Right", "apple": null, "status": "GameOver", ... } }

When a fixture test fails, find the case by name, draw the board, and compute by hand what each phase (compute, decide, build) should give. Then compare with what your function returns. Usually one field differs, and the field tells you which phase is wrong.

From Scala: in Scala, the enum values are qualified (Status.Playing, Direction.Up); add import Direction.* and import Status.* at the top of Rules.scala if you prefer the short names. Comparing a Status uses == (or !=). In F#, union cases are unqualified (Playing, Up), and record fields are PascalCase (state.NextDir).

Using what you built

rules builds on Modules 1 and 2:

  • delta and opposite from model;
  • dropLast and contains from util;
  • pointEquals (JS) to compare points; == / = in Scala and F#.

In JS, import them at the top of rules.js, for example import { delta, opposite, pointEquals } from './model.js' and import { dropLast, contains } from './util.js'. In Scala, all files are in package snake, so write Model.delta, Util.dropLast (or import Model.*). In F#, refer to Model.delta and Util.dropLast, or open those modules.

Exercises

Exercise 3.1 — move

JavaScript

export const move = (p, dir) => ...         // move({x: 2, y: 2}, 'Up') -> {x: 2, y: 1}

Scala

def move(p: Point, dir: Direction): Point

F#

let move (p: Point) (dir: Direction) : Point

Fixture: fixtures/m03-move.json (move-up, move-down, move-left, move-right, move-does-not-wrap-at-the-edge).

Exercise 3.2 — inBounds

JavaScript

export const inBounds = (state, p) => ...   // true iff 0 <= p.x < width and 0 <= p.y < height

Scala

def inBounds(state: GameState, p: Point): Boolean

F#

let inBounds (state: GameState) (p: Point) : bool

Fixture: fixtures/m03-in-bounds.json (corners, negatives, x-equals-width, y-equals-height, middle). Watch out: four conditions, and two of them are strict.

Exercise 3.3 — turn

Returns state unchanged unless the status is Playing and dir is not the opposite of state.dir. Otherwise sets nextDir = dir (and nothing else).

JavaScript

export const turn = (state, dir) => ...

Scala

def turn(state: GameState, dir: Direction): GameState

F#

let turn (state: GameState) (dir: Direction) : GameState

Fixture: fixtures/m03-turn.json: turn-normal-perpendicular, turn-same-direction-is-fine, turn-reverse-rejected, turn-replaces-pending-turn, turn-against-last-moved-dir (Puzzle 2), turn-ignored-when-paused, turn-ignored-when-game-over, turn-ignored-when-won.

Watch out: compare against dir, not nextDir. And "unchanged" means returning the very same state, not a copy with the same values; both pass the tests, but returning state makes the intent obvious.

Exercise 3.4 — advance

One step of the game. Unchanged unless Playing. Otherwise, with d = nextDir and h = move(head, d):

  • eating is true when there is an apple and h is on it;
  • the remaining body is the whole snake when eating, otherwise the snake without its last cell;
  • if h is out of bounds or body contains h: status GameOver, dir = d, snake unchanged;
  • otherwise: the snake becomes h followed by body, dir = d, the apple is removed if eaten, and the score goes up by one if eaten.

JavaScript

export const advance = (state) => ...

Scala

def advance(state: GameState): GameState

F#

let advance (state: GameState) : GameState

Fixture: fixtures/m03-advance.json, twelve cases including advance-plain-move, advance-moves-in-nextDir-and-records-it-as-dir, advance-eat-grows-scores-clears-apple, advance-into-wall-is-game-over, advance-into-own-body-is-game-over, advance-into-vacating-tail and advance-into-tail-while-eating (Puzzle 1), and the three "unchanged" cases for Paused, GameOver, Won.

Watch out:

  • In JS the apple may be null; check before comparing. In Scala/F# compare apple with Some(h) / Some h, or pattern-match.
  • Eating does not place a new apple. advance sets it to none; Module 4 adds the spawning.
  • The score only changes when eating; seed and frame never change here.

Run

  • JS: cd js && node --test "test/m03-*.test.js"
  • Scala: cd scala && ./test.sh M03
  • F#: cd fsharp && ./test.sh M03

Hints

Exercise 3.1 (move) — nudge

delta(dir) gives you a point to add.

Exercise 3.1 (move) — stronger hint

Build a new point whose x is p.x + d.x and whose y is p.y + d.y, where d = delta(dir).

Exercise 3.1 (move) — near-solution (try the others first)
export const move = (p, dir) => { const d = delta(dir); return point(___, ___) }
def move(p: Point, dir: Direction): Point = { val d = Model.delta(dir); Point(___, ___) }
let move (p: Point) (dir: Direction) : Point = let d = Model.delta dir in { X = ___; Y = ___ }
Exercise 3.2 (inBounds) — nudge

It is one boolean expression with four comparisons joined by "and".

Exercise 3.2 (inBounds) — stronger hint

Valid x values on a board of width 5 are 0, 1, 2, 3, 4. So x >= 0 and x < width. Same for y and height.

Exercise 3.3 (turn) — nudge

Write the guard first: in which two situations does the function return state as it is? Then the one-field copy.

Exercise 3.3 (turn) — stronger hint

The guard is "status is not Playing, or dir equals opposite(state.dir)". If the guard holds, return state; otherwise return a copy with nextDir replaced. Re-read Puzzle 2 if turn-against-last-moved-dir fails.

Exercise 3.3 (turn) — near-solution (try the others first)
export const turn = (state, dir) =>
  state.status !== ___ || dir === opposite(___) ? state : { ...state, ___ }
def turn(state: GameState, dir: Direction): GameState =
  if state.status != ___ || dir == Model.opposite(___) then state
  else state.copy(___)
let turn (state: GameState) (dir: Direction) : GameState =
    if state.Status <> ___ || dir = Model.opposite ___ then state
    else { state with ___ }
Exercise 3.4 (advance) — nudge

Follow "compute, decide, build". Compute four named values in this order: the direction d, the new head h, eating, body. Then decide between two outcomes.

Exercise 3.4 (advance) — stronger hint
  • The head is the first element of the snake (state.snake[0], state.snake.head, List.head state.Snake).
  • body uses dropLast when not eating.
  • The collision test uses inBounds and contains(body, h, pointEquals) (Scala/F#: body.contains(h) / List.contains h body work too, thanks to structural equality).
  • The new snake is h in front of body: [h, ...body], h :: body.
  • If advance-into-vacating-tail fails, you are probably checking against the whole snake instead of body. If advance-into-tail-while-eating fails, you are probably dropping the tail even when eating.
Exercise 3.4 (advance) — near-solution (try the others first)
export const advance = (state) => {
  if (state.status !== 'Playing') return state
  const d = ___
  const h = move(___, d)
  const eating = state.apple !== null && ___
  const body = eating ? ___ : ___
  if (!inBounds(state, h) || ___) return { ...state, dir: d, status: ___ }
  return { ...state, snake: ___, dir: d, apple: ___, score: ___ }
}
def advance(state: GameState): GameState =
  if state.status != Status.Playing then state
  else
    val d = ___
    val h = move(___, d)
    val eating = state.apple.contains(___)
    val body = if eating then ___ else ___
    if !inBounds(state, h) || ___ then state.copy(dir = d, status = ___)
    else state.copy(snake = ___, dir = d, apple = ___, score = ___)
let advance (state: GameState) : GameState =
    if state.Status <> Playing then state
    else
        let d = ___
        let h = move ___ d
        let eating = (state.Apple = Some h)
        let body = if eating then ___ else ___
        if not (inBounds state h) || ___ then { state with Dir = d; Status = ___ }
        else { state with Snake = ___; Dir = d; Apple = ___; Score = ___ }

Check your understanding

  1. Why does turn return the state unchanged when paused, instead of throwing? Who would have to deal with the exception, and how would that change the rest of the program?
  2. Without looking: on a 5 x 5 board, the snake is (1,1) (1,2) (2,2) (2,1) heading Up with nextDir = Right and an apple at (2,1). What does advance return? Now the same with no apple.
  3. Explain to a friend, with a drawing, why turn must compare against dir and not nextDir. What sequence of key presses kills the snake in the wrong version?
  4. With the correct turn, the player presses Up then Left within one tick, intending a quick U-turn, and only Up happens. Sketch a different model that would honour both presses. What would you add to GameState, and how would advance change?
  5. After advance crashes into a wall, the snake is unchanged but dir is updated. What invariant does that keep? Would anything visible break if dir were left unchanged?
  6. advance does not place a new apple after eating. Why is it a good idea to keep that out of advance? (Think about what advance would need as input.)
  7. Predict: advance(advance(s)) where s is a GameOver state. And turn(advance(s), 'Up') for the same s.
  8. Why are the fixture boards 5 x 5 rather than the real 20 x 20? What does that tell you about the decision to store width and height in the state?

In production

  • Redux reducers are rules of exactly this shape, (state, action) => newState, and the Redux docs insist they must be pure and must not mutate: see Reducers.
  • Elm's update function plays the same role; the Elm Architecture guide shows it for a counter, then for larger apps.
  • Games that need exact replays or networked lockstep (many real-time strategy games) are built around deterministic step functions like advance: all players run the same rules on the same inputs and get the same state. See Glenn Fiedler's Deterministic Lockstep.

Further reading