24 KiB
Module 3 — Pure game rules
Time: ~3–4 h · You'll edit: JS
js/src/rules.js· Scalascala/src/Rules.scala· F#fsharp/src/Rules.fsTests: JScd js && node --test "test/m03-*.test.js"· Scalacd scala && ./test.sh M03· F#cd fsharp && ./test.sh M03Prerequisites: 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 → Stateusing 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
turnmust compare againstdir(last moved) and notnextDir; - 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
headOptiontohead, andOptionto 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:
- 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. - Decide which outcome applies (a collision? a normal move?).
- 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 frompin directiondir. It isp + delta(dir). It does not wrap around the edges: movingRightfromx = 4on a 5-wide board givesx = 5, off the board. Deciding what that means is another rule's job (fixturemove-does-not-wrap-at-the-edge).inBounds(state, p): ispon the board?0 <= x < widthand0 <= y < height. Note the strict<: on a 5-wide board,x = 5is outside (fixturesx-equals-width,y-equals-height).turn(state, dir): the player asks to go indir. Only whilePlaying, and never straight back the way the snake came. If allowed, it setsnextDir; it does not move anything.advance(state): one step of time. The head moves one cell innextDir(which then becomesdir). 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):
- Press Up. Opposite of
nextDir(Right) is Left; Up is fine. NownextDir = Up. The snake has not moved:diris still Right. - Press Left. Opposite of
nextDir(Up) is Down; Left is fine! NownextDir = Left. - 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):
- Press Up. Opposite of
dir(Right) is Left; Up is fine.nextDir = Up. - Press Left. Opposite of
dir(still Right, nothing has moved) is Left: rejected. The state is unchanged;nextDirstays Up. - 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',dirset 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); addimport Direction.*andimport Status.*at the top ofRules.scalaif you prefer the short names. Comparing aStatususes==(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:
deltaandoppositefrommodel;dropLastandcontainsfromutil;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):
eatingis true when there is an apple andhis on it;- the remaining
bodyis the whole snake when eating, otherwise the snake without its last cell; - if
his out of bounds orbodycontainsh: statusGameOver,dir = d, snake unchanged; - otherwise: the snake becomes
hfollowed bybody,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# compareapplewithSome(h)/Some h, or pattern-match. - Eating does not place a new apple.
advancesets it to none; Module 4 adds the spawning. - The score only changes when eating;
seedandframenever 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.2 (inBounds) — near-solution (try the others first)
export const inBounds = (state, p) => p.x >= 0 && p.x < ___ && ___ && ___
def inBounds(state: GameState, p: Point): Boolean = p.x >= 0 && p.x < ___ && ___ && ___
let inBounds (state: GameState) (p: Point) : bool = p.X >= 0 && p.X < ___ && ___ && ___
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). bodyusesdropLastwhen not eating.- The collision test uses
inBoundsandcontains(body, h, pointEquals)(Scala/F#:body.contains(h)/List.contains h bodywork too, thanks to structural equality). - The new snake is
hin front ofbody:[h, ...body],h :: body. - If
advance-into-vacating-tailfails, you are probably checking against the whole snake instead ofbody. Ifadvance-into-tail-while-eatingfails, 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
- Why does
turnreturn 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? - Without looking: on a 5 x 5 board, the snake is
(1,1) (1,2) (2,2) (2,1)heading Up withnextDir = Rightand an apple at (2,1). What doesadvancereturn? Now the same with no apple. - Explain to a friend, with a drawing, why
turnmust compare againstdirand notnextDir. What sequence of key presses kills the snake in the wrong version? - 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 toGameState, and how wouldadvancechange? - After
advancecrashes into a wall, the snake is unchanged butdiris updated. What invariant does that keep? Would anything visible break ifdirwere left unchanged? advancedoes not place a new apple after eating. Why is it a good idea to keep that out ofadvance? (Think about whatadvancewould need as input.)- Predict:
advance(advance(s))wheresis aGameOverstate. Andturn(advance(s), 'Up')for the sames. - 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
widthandheightin 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
updatefunction 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
- Total functional programming (Wikipedia) — the idea of insisting every function is total, taken to its logical end.
- Snake (video game genre) (Wikipedia) — the history of the game, and the many rule variants (wrapping edges, tail biting).
- Mostly Adequate Guide, chapter 3: Pure Happiness with Pure Functions — the case for pure functions in JavaScript, with testing in mind.
- Scott Wlaschin, "Designing with types" — useful again here: which checks in
advancecould types remove? - Why Functional Programming Matters — Hughes' argument that small pure pieces glue together into larger ones, which is what
turnandadvanceare about to do in Module 5.