Module 3
Pure game rules
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.
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.
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. Our rules follow one simple convention: when a rule does not apply, it returns the state unchanged. turn while paused returns the same state.
From everyday programming: the usual alternative is a method that throws an IllegalStateException when it is called at the wrong time. A total rule behaves more like a disabled button in a user interface: pressing it is allowed, and nothing happens.
A typical rule therefore starts with a guard:
// (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) } }
def heal(hero: Hero, amount: Int): Hero = if hero.hp <= 0 then hero else hero.copy(hp = math.min(hero.maxHp, hero.hp + amount))
let heal (hero: Hero) (amount: int) : Hero =
if hero.Hp <= 0 then hero
else { hero with Hp = min hero.MaxHp (hero.Hp + amount) }
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.
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.
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.
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.
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 with
nextDir = 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? - After
advancecrashes into a wall, the snake is unchanged butdiris updated. What invariant does that keep? advancedoes not place a new apple after eating. Why is it a good idea to keep that out ofadvance?- Why are the fixture boards 5 x 5 rather than the real 20 x 20? What does that tell you about storing
widthandheightin the state?