# 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**
```js
// (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**
```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#**
```fsharp
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:
```json
{ "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
> **About test names.** This module's tests are all fixture-based: one test per fixture case, named after the function and the case. The JS runner prints them with the module in front (`m03 advance: advance-into-vacating-tail`); Scala and F# print `advance: advance-into-vacating-tail`. Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M03Rules.test.scala` or `fsharp/tests/M03Rules.fs`.
### Exercise 3.1 — `move`
**JavaScript**
```js
export const move = (p, dir) => ... // move({x: 2, y: 2}, 'Up') -> {x: 2, y: 1}
```
**Scala**
```scala
def move(p: Point, dir: Direction): Point
```
**F#**
```fsharp
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**
```js
export const inBounds = (state, p) => ... // true iff 0 <= p.x < width and 0 <= p.y < height
```
**Scala**
```scala
def inBounds(state: GameState, p: Point): Boolean
```
**F#**
```fsharp
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**
```js
export const turn = (state, dir) => ...
```
**Scala**
```scala
def turn(state: GameState, dir: Direction): GameState
```
**F#**
```fsharp
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**
```js
export const advance = (state) => ...
```
**Scala**
```scala
def advance(state: GameState): GameState
```
**F#**
```fsharp
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)
```js
export const move = (p, dir) => { const d = delta(dir); return point(___, ___) }
```
```scala
def move(p: Point, dir: Direction): Point = { val d = Model.delta(dir); Point(___, ___) }
```
```fsharp
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)
```js
export const inBounds = (state, p) => p.x >= 0 && p.x < ___ && ___ && ___
```
```scala
def inBounds(state: GameState, p: Point): Boolean = p.x >= 0 && p.x < ___ && ___ && ___
```
```fsharp
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)
```js
export const turn = (state, dir) =>
state.status !== ___ || dir === opposite(___) ? state : { ...state, ___ }
```
```scala
def turn(state: GameState, dir: Direction): GameState =
if state.status != ___ || dir == Model.opposite(___) then state
else state.copy(___)
```
```fsharp
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)
```js
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: ___ }
}
```
```scala
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 = ___)
```
```fsharp
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](https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers).
- **Elm's** `update` function plays the same role; the [Elm Architecture](https://guide.elm-lang.org/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](https://gafferongames.com/post/deterministic_lockstep/).
## Further reading
- [Total functional programming (Wikipedia)](https://en.wikipedia.org/wiki/Total_functional_programming) — the idea of insisting every function is total, taken to its logical end.
- [Snake (video game genre) (Wikipedia)](https://en.wikipedia.org/wiki/Snake_%28video_game_genre%29) — the history of the game, and the many rule variants (wrapping edges, tail biting).
- [Mostly Adequate Guide, chapter 3: Pure Happiness with Pure Functions](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — the case for pure functions in JavaScript, with testing in mind.
- [Scott Wlaschin, "Designing with types"](https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/) — useful again here: which checks in `advance` could types remove?
- [Why Functional Programming Matters](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf) — Hughes' argument that small pure pieces glue together into larger ones, which is what `turn` and `advance` are about to do in Module 5.