22. Snake¶
Paddle and Bricks move smoothly: a ball goes a few units further in every step of the loop. Snake moves in jumps. The field is a grid of cells, and the snake moves exactly one cell at a time, a few times a second, faster with every apple it eats. Steer it to the apples, let it grow, and never bite the wall or yourself.

Snake is simpler than Bricks, which is why it is here. With few rules, there is room for three questions that every later game needs answered. How do rules with a pace of their own fit into a loop of 120 steps a second? How do we keep key presses that come between two moves? And how does a game that depends on chance become exactly repeatable, so that the title screen can replay your best game, cell by cell?
What you will learn
- A tick for the rules, apart from the update steps and the frames, and a pace that changes while the game runs
- Drawing between ticks, so a snake that moves in jumps glides
- Input between ticks: a queue of turns, so no key press is lost
- Random numbers that repeat: a seed, a generator of our own, and a second generator for the effects
- Replays: a whole game as a seed and a few dozen turns, played back on the title screen
- A computer player that counts its room with the flood fill from Mines
The complete game is in amade-snake-game.
The rules¶
- The field is a grid of 32 × 18 cells; the outermost ring is a stone wall. The snake starts four cells long in the middle, heading right.
- The snake moves one cell per tick. The arrow keys turn it; it cannot turn back into its own neck.
- An apple gives 10 points and makes the snake one cell longer and a little faster. The next apple appears on a random empty cell.
- After every fifth apple, a golden apple appears for 40 ticks. It gives 50 points and does not make the snake longer.
- Running into the wall or the snake's own body ends the game. The tail is not in the way, because it moves on in the same tick.
- The best scores are kept. The title screen replays the best game, and lets the computer play in between.
Step 1: Cells and the snake¶
The field is 640 × 400 logical units, as always: a band of 40 units at the top for the score, and below it 32 × 18 cells of 20 units. Positions in the rules are cells, whole numbers, and only the painter turns them into units:
package eu.amadeos.games.snake.domain;
/** A square of the field, counted from the top left corner; the outermost ring is the wall. */
public record Cell(int x, int y) {
/** The neighbour in a direction. */
public Cell step(Direction direction) {
return new Cell(x + direction.dx(), y + direction.dy());
}
/** Steps from here to {@code other}, along the grid. */
public int distance(Cell other) {
return Math.abs(x - other.x) + Math.abs(y - other.y);
}
}
The snake is a Deque<Cell>, the head first. Moving one cell means adding a new head and removing the tail; growing means adding a head and keeping the tail. A new game puts the snake in the middle and the first apple somewhere free:
public Game(long seed) {
this.seed = seed;
this.random = new SeededRandom(seed);
// In the middle of the field, heading right.
int y = Field.ROWS / 2;
for (int i = 0; i < Field.START_LENGTH; i++) body.addLast(new Cell(Field.COLUMNS / 3 - i, y));
previous = List.copyOf(body);
apple = freeCell();
}
One move is a tick. Like update in Bricks, it knows no clock; it only moves by one cell, and reports what happened as events:
/** One tick: the snake moves by one cell, eats, grows or crashes. */
public void tick() {
events.clear();
if (phase == Phase.OVER) return;
ticks++;
if (!turns.isEmpty()) heading = turns.removeFirst();
Cell next = head().step(heading);
if (!isFree(next)) {
previous = List.copyOf(body); // the snake stops where it is
end(new Event.Crashed(next), false);
return;
}
previous = List.copyOf(body);
boolean grows = next.equals(apple);
body.addFirst(next);
if (!grows) body.removeLast();
if (next.equals(golden)) eatGolden(next);
else if (golden != null && --goldenTicks == 0) {
events.add(new Event.GoldenGone(golden));
golden = null;
}
if (grows) eatApple(next);
}
public sealed interface Event {
/** The snake ate an apple at {@code cell}; {@code golden} for a golden one. */
record Ate(Cell cell, boolean golden) implements Event {
}
record GoldenAppeared(Cell cell) implements Event {
}
/** A golden apple was not eaten in time. */
record GoldenGone(Cell cell) implements Event {
}
/** The head ran into the wall or the snake's own body at {@code cell}. */
record Crashed(Cell cell) implements Event {
}
record GameOver(GameResult result) implements Event {
}
}
isFree holds the one subtle rule. The cell the tail occupies now will be empty after this tick, so the head may move into it, unless the snake eats an apple there and the tail stays. Tests show the rules with a snake the test puts where it wants:
@Test
void anAppleMakesTheSnakeLongerAndFaster() {
game.putApple(new Cell(11, 9));
game.tick();
assertEquals(5, game.length());
assertEquals(10, game.score());
assertEquals(List.of(new Event.Ate(new Cell(11, 9), false)), game.events());
assertEquals(0.148, game.interval(), 1e-9);
assertFalse(game.body().contains(game.apple()), "the next apple lies elsewhere");
}
Step 2: A pace of its own¶
A tick is not an update step. The loop still steps 120 times a second, as in Paddle and Bricks. The snake starts at a cell every 150 ms, nearly seven cells a second, and gets faster with every apple:
/** The pace at the start: a cell every 150 ms, nearly seven a second. */
public static final double START_INTERVAL = 0.15;
/** Every apple takes this much off the interval, down to {@link #MIN_INTERVAL}. */
public static final double SPEED_UP = 0.002;
/** The fastest pace, reached with the 35th apple: a cell every 80 ms, 12.5 a second. */
public static final double MIN_INTERVAL = 0.08;
/** Seconds per tick: the more the snake has eaten, the faster it moves. */
public double interval() {
return Math.max(Field.MIN_INTERVAL, Field.START_INTERVAL - eaten * Field.SPEED_UP);
}
Why not simply run the loop at seven steps a second? Three reasons. The pace changes during the game, and a loop with a changing step length gives up what made fixed steps worth having. The keys would be read only seven times a second, so a short tap could fall between two looks. And everything else on the screen, the splinters, the blinking golden apple and the pause screen, would move in jumps too.
So the loop keeps its 120 steps, and the rules get a clock of their own, the engine's Ticker. Each update step passes its time to the ticker, and the ticker says whether a tick is due:
public final class Ticker {
private static final double EPSILON = 1e-9;
private double interval;
private double elapsed;
private long count;
/** @param interval seconds between two ticks */
public Ticker(double interval) {
setInterval(interval);
}
/** Adds {@code seconds} of game time and returns how many ticks are due now, usually 0 or 1. */
public int advance(double seconds) {
elapsed += Math.max(0, seconds);
int due = 0;
// A nanosecond of tolerance: 120 steps of 1/120 s add up to 0.99999999 s in floating point, and a
// tick due at exactly one second must not wait for the next step.
while (elapsed + EPSILON >= interval) {
elapsed = Math.max(0, elapsed - interval);
due++;
}
count += due;
return due;
}
/**
* How far the time is from the last tick to the next, 0 to 1, for drawing in between.
*
* @param ahead time that has passed since the last update step, usually {@code alpha} times the step
*/
public double progress(double ahead) {
return Math.clamp((elapsed + Math.max(0, ahead)) / interval, 0, 1);
}
/** Changes the pace from the next tick on; the time since the last tick is kept. */
public void setInterval(double interval) {
if (!(interval > 0)) throw new IllegalArgumentException("interval must be positive: " + interval);
this.interval = interval;
}
public double interval() {
return interval;
}
/** The ticks so far. */
public long count() {
return count;
}
/** Back to the start: no ticks, no time. */
public void reset() {
elapsed = 0;
count = 0;
}
}
It is FixedStep once more, one level up: time is collected, and what is left over waits for the next step. The difference is that the interval may change between two ticks, and the time already collected is kept when it does.
@Test
void ticksAreSlowerThanSteps() {
Ticker ticker = new Ticker(0.1); // ten ticks a second
int ticks = 0;
for (int i = 0; i < 120; i++) ticks += ticker.advance(STEP); // one second of update steps
assertEquals(10, ticks);
assertEquals(10, ticker.count());
}
@Test
void progressRunsFromOneTickToTheNext() {
Ticker ticker = new Ticker(0.1);
assertEquals(0, ticker.advance(0.025));
assertEquals(0.25, ticker.progress(0), 1e-9);
assertEquals(0.5, ticker.progress(0.025), 1e-9, "a frame drawn between two steps looks ahead");
assertEquals(1, ticker.advance(0.1));
assertEquals(0.25, ticker.progress(0), 1e-9, "the rest of the time carries over");
}
Pitfall: 120 times 1/120 is not 1
In floating point, 120 steps of 1/120 second add up to 0.99999999 seconds, not one second. A tick due after exactly one second would then come a step late, and the snake would stutter now and then. Ticker therefore allows a nanosecond of tolerance. FixedStep counts whole nanoseconds and has no such problem; the ticker works in seconds because the rules speak in seconds per tick.
The game's own test steps the scenes 120 times a second, as the loop does, and counts the ticks:
@Test
void theSnakeMovesAtItsOwnPaceWhileTheLoopSteps120TimesASecond() {
tap(KeyEvent.VK_ENTER);
SnakeGame.PlayScene play = assertInstanceOf(SnakeGame.PlayScene.class, game.top());
seconds(SnakeGame.READY_SECONDS);
long before = play.game().ticks();
seconds(1.5);
// 1.5 seconds at 0.15 seconds a cell, whatever the update rate.
assertEquals(10, play.game().ticks() - before, 1);
}
Drawing between ticks¶
A snake drawn only where the rules put it jumps a whole cell, seven times a second. That is how the first snake games looked, and it is easy to do better. The ticker knows how far the time is between the last tick and the next, from 0 to 1. The game keeps the snake as it was before the last tick, so each segment can be drawn that far on its way from its old cell to its new one:
/**
* @param between how far the time is from the last tick to the next, 0 to 1: each segment is drawn that
* far on its way from its cell before the tick to its cell after it
*/
void paint(Graphics2D g, Game game, Particles particles, double between, double alpha) {
background.draw(g, 0, 0);
apple.draw(g, Field.x(game.apple().x()), Field.y(game.apple().y()));
// The golden apple blinks in its last ticks.
Cell gold = game.golden();
if (gold != null && (game.goldenTicksLeft() > 10 || game.goldenTicksLeft() % 2 == 0)) {
golden.draw(g, Field.x(gold.x()), Field.y(gold.y()));
}
List<Cell> now = game.body();
List<Cell> before = game.previousBody();
double t = game.phase() == Game.Phase.OVER ? 1 : between;
// From the tail to the head, so the head lies on top.
for (int i = now.size() - 1; i >= 0; i--) {
Cell to = now.get(i);
Cell from = i < before.size() ? before.get(i) : to; // a new tail segment appears in place
double x = from.x() + (to.x() - from.x()) * t;
double y = from.y() + (to.y() - from.y()) * t;
(i == 0 ? heads.get(game.heading()) : segment).draw(g, Field.x(x), Field.y(y));
}
particles.render(g, alpha);
}
Each segment moves into the cell the segment in front of it had, so it always moves in a straight line to a neighbouring cell, and the snake bends at its corners by itself. A segment that has just grown has no old cell; it appears where it is.
The scene asks the ticker for its progress with alpha * STEP as the time ahead. A frame is drawn between two update steps, and alpha says how far between, as in Paddle. Without it, the snake would be drawn where it was at the last update step, not at the moment of the frame. Frames and steps do not keep in line, so it would move unevenly: a little more in one frame, a little less in the next.
Behind the scenes: pixel mode
In pixel mode, the snake glides in whole logical pixels: twenty positions from one cell to the next. That is how the arcade machines of the 1980s moved their sprites, and why their smooth movement still looked crisp.
Step 3: Turns between ticks¶
The player presses up, and a moment later left, to turn the snake around a corner and back. Both presses come within one tick. A game that only remembers "the last key pressed" loses the up: the snake turns left into its own neck and dies, and the player rightly blames the game.
The turns therefore wait in a short queue, and each tick takes one:
/**
* The player wants to go {@code direction} next. Turns wait in a queue for the next ticks, so two quick
* presses within one tick (up, then left) make two turns instead of losing one. A turn back into the
* snake's own neck, or in the direction it already goes, is ignored.
*
* @return whether the turn was accepted
*/
public boolean turn(Direction direction) {
if (phase == Phase.OVER || turns.size() >= MAX_QUEUED_TURNS) return false;
Direction last = turns.isEmpty() ? heading : turns.getLast();
if (direction == last || direction == last.opposite()) return false;
turns.addLast(direction);
return true;
}
A turn is compared with the last queued direction, not with the current heading. Heading right, up then down would otherwise both be accepted, and the second would turn the snake back into itself one tick later. Two places in the queue are enough for any human hand; more would let a nervous player queue up moves they no longer mean.
@Test
void twoQuickTurnsWithinOneTickAreBothCarriedOut() {
game.putApple(new Cell(1, 1));
assertTrue(game.turn(Direction.UP));
assertTrue(game.turn(Direction.LEFT));
assertFalse(game.turn(Direction.DOWN), "two turns wait at most");
game.tick();
assertEquals(new Cell(10, 8), game.head());
game.tick();
assertEquals(new Cell(9, 8), game.head());
}
@Test
void theSnakeCannotTurnBackIntoItsNeck() {
assertFalse(game.turn(Direction.LEFT), "it heads right");
assertFalse(game.turn(Direction.RIGHT), "already its direction");
assertTrue(game.turn(Direction.UP));
assertFalse(game.turn(Direction.DOWN), "back after the queued turn up");
}
Events or states?
In Paddle and Bricks, the paddles asked isDown: is the key held right now? A snake does not care how long a key is held, only that it was pressed. So Snake asks wasPressed, as Bricks did for launching the ball. The keyboard collects presses between two update steps, and the game collects them between two ticks.
Step 4: Chance that repeats¶
Where does the next apple appear? On a random empty cell:
/**
* A random empty cell, or {@code null} if there is none. The empty cells are counted in a fixed order,
* row by row, and the random number picks one of them: the same state and the same generator always
* give the same cell.
*/
private Cell freeCell() {
Set<Cell> taken = new HashSet<>(body);
if (apple != null) taken.add(apple);
if (golden != null) taken.add(golden);
List<Cell> free = new ArrayList<>(Field.INSIDE);
for (int y = 1; y < Field.ROWS - 1; y++) {
for (int x = 1; x < Field.COLUMNS - 1; x++) {
Cell cell = new Cell(x, y);
if (!taken.contains(cell)) free.add(cell);
}
}
return free.isEmpty() ? null : free.get(random.nextInt(free.size()));
}
"Random" is the interesting word here. A computer has no dice. Its random numbers come from a formula that turns one number into the next. Start the formula with the same number, the seed, and it produces the same sequence again. Usually that is hidden: Java seeds its generators from the clock. For a game, it is a gift. If all chance in the rules comes from one generator with a known seed, and the rules know no time but their ticks, then the same seed and the same turns at the same ticks play the same game. That is called deterministic. It makes three things possible: tests that play whole games, a "game of the day" that everyone plays with the same apples, and replays.
Which of Java's generators repeats reliably? Math.random() cannot be seeded at all. RandomGenerator.getDefault() names no algorithm; a later Java may choose another one, and every saved replay would break. java.util.Random is specified to the bit and does repeat, but it cannot tell its seed and cannot be copied or split. So the engine has a generator of its own, a dozen lines of a well-known algorithm (SplitMix64), written out so that no library update can ever change it:
public final class SeededRandom implements RandomGenerator {
private final long seed;
private long state;
public SeededRandom(long seed) {
this.seed = seed;
this.state = seed;
}
/** A new seed from the clock, for a game that should differ from the last one. */
public static long freshSeed() {
return new SeededRandom(System.nanoTime() ^ System.currentTimeMillis()).nextLong();
}
/** The seed this generator started with; with it, the same numbers can be drawn again. */
public long seed() {
return seed;
}
@Override
public long nextLong() {
long z = state += 0x9E3779B97F4A7C15L;
z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L;
z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL;
return z ^ (z >>> 31);
}
/** 0 inclusive to {@code bound} exclusive, every value equally likely. */
@Override
public int nextInt(int bound) {
if (bound <= 0) throw new IllegalArgumentException("bound must be positive: " + bound);
// 31 random bits; the few largest values that would favour small results are drawn again.
int bits;
int value;
do {
bits = (int) (nextLong() >>> 33);
value = bits % bound;
} while (bits - value + (bound - 1) < 0);
return value;
}
nextInt(bound) is written out too, because turning a 64-bit number into a number below bound is a formula of its own. Dividing and keeping the remainder makes small results a little more likely than large ones, so the few largest raw values that cause this are drawn again. The test pins the first numbers. If they ever change, every replay is broken, and the test says so first:
/** The published first numbers of SplitMix64 for seed 0: if these change, every replay breaks. */
@Test
void theNumbersNeverChange() {
SeededRandom random = new SeededRandom(0);
assertEquals(0xE220A8397B1DCDAFL, random.nextLong());
assertEquals(0x6E789E6AA1B965F4L, random.nextLong());
assertEquals(0x06C45D188009454FL, random.nextLong());
}
@Test
void theSameSeedGivesTheSameNumbers() {
SeededRandom a = new SeededRandom(2026);
SeededRandom b = new SeededRandom(2026);
int[] fromA = IntStream.range(0, 100).map(i -> a.nextInt(1000)).toArray();
int[] fromB = IntStream.range(0, 100).map(i -> b.nextInt(1000)).toArray();
assertArrayEquals(fromA, fromB);
}
Game gets a seed, not a generator, and makes its own. Nobody outside can draw a number from it and change the next apple by accident.
Effects need chance too¶
The juice of an eaten apple flies in random directions. If the particles drew their numbers from the rules' generator, a burst of twenty particles would move the generator twenty numbers on, and the next apple would appear elsewhere. Worse: turning the effects off, or drawing fewer particles on a slow computer, would change the game. Chance for the looks must never come from the rules' generator. Snake's effects get a generator of their own. For games where both start from one seed, fork makes a second generator that is independent of the first:
/**
* A second generator whose seed is drawn from this one: its numbers are independent of this one's, and
* drawing from it never changes what this one draws next.
*/
public SeededRandom fork() {
return new SeededRandom(nextLong());
}
@Test
void aForkDoesNotDisturbItsParent() {
SeededRandom quiet = new SeededRandom(7);
quiet.fork();
int expected = quiet.nextInt(100);
SeededRandom busy = new SeededRandom(7);
SeededRandom looks = busy.fork();
for (int i = 0; i < 1000; i++) looks.nextDouble(); // a thousand splinters
assertEquals(expected, busy.nextInt(100), "the effects did not change the rules' next number");
}
What makes a game deterministic
- All chance in the rules comes from one seeded generator, used in a fixed order.
- Time exists only as ticks or fixed steps, never as
System.nanoTime()inside the rules. - Nothing in the rules depends on the iteration order of a
HashSetorHashMap.freeCelluses a set only to look up taken cells; the order comes from the two loops over rows and columns. - Effects, sound and drawing may do what they like, as long as they only read the rules.
Since Java 17, +, -, * and / on double give the same result on every computer. Functions such as Math.sin may still differ in the last bit between processors; StrictMath does not. Bricks, with its angles, would need StrictMath for replays that work everywhere. Snake has whole cells and needs no such care.
Step 5: Replays¶
With a deterministic game, a replay is small: the seed, and every turn with the tick before which the rules received it. The engine's Replay holds exactly that:
public final class Replay<A extends Enum<A>> {
/** An action given to the rules before tick number {@code tick} (counted from 0). */
public record Entry<A>(long tick, A action) {
}
private final Class<A> actions;
private final long seed;
private final List<Entry<A>> entries = new ArrayList<>();
public Replay(Class<A> actions, long seed) {
this.actions = actions;
this.seed = seed;
}
public long seed() {
return seed;
}
public List<Entry<A>> entries() {
return Collections.unmodifiableList(entries);
}
/** Records an action; ticks must not run backwards. */
public void record(long tick, A action) {
if (tick < 0 || (!entries.isEmpty() && tick < entries.getLast().tick())) {
throw new IllegalArgumentException("tick " + tick + " before the last recorded one");
}
entries.add(new Entry<>(tick, action));
}
/** Plays the actions back in order: ask it before each tick. */
public Playback playback() {
return new Playback();
}
public final class Playback {
private int next;
private Playback() {
}
/** The actions recorded for tick {@code tick}, in their order; ticks must be asked in order. */
public List<A> actionsAt(long tick) {
List<A> due = new ArrayList<>(1);
while (next < entries.size() && entries.get(next).tick() <= tick) {
due.add(entries.get(next++).action());
}
return due;
}
/** True when every recorded action has been played. */
public boolean finished() {
return next >= entries.size();
}
}
The play scene records every turn the game accepts, at the game's tick count. Before the first turn, a new game waits one and a half seconds ("Get ready!"), unless the player turns the snake first:
/** A game: the keys turn the snake, the ticker moves it, every accepted turn goes into the replay. */
final class PlayScene implements Scene {
private final Game game = new Game(seeds.getAsLong());
private final Replay<Direction> replay = new Replay<>(Direction.class, game.seed());
private final Ticker ticker = new Ticker(game.interval());
private double ready = READY_SECONDS;
PlayScene() {
effects.clear();
}
@Override
public void update(double seconds) {
if (controls.wasPressed(Action.PAUSE)) {
scenes.push(new PauseScene());
return;
}
for (Direction direction : pressedDirections()) {
if (game.turn(direction)) {
replay.record(game.ticks(), direction);
ready = 0; // the first turn starts the snake at once
}
}
effects.update(seconds);
if (ready > 0) {
ready -= seconds;
return;
}
for (int i = ticker.advance(seconds); i > 0; i--) {
game.tick();
effects.react(game.events(), true);
ticker.setInterval(game.interval());
if (game.phase() == Game.Phase.OVER) {
finish();
return;
}
}
}
private void finish() {
for (Event event : game.events()) {
if (event instanceof Event.GameOver(GameResult result)) {
scenes.replace(new OverScene(game, result));
// The presenter lives on the Swing thread: hand the result and the replay over there.
BiConsumer<GameResult, Replay<Direction>> listener = onGameOver;
ui.execute(() -> listener.accept(result, replay));
}
}
}
@Override
public void render(Graphics2D g, double alpha) {
double between = ready > 0 ? 0 : ticker.progress(alpha * STEP);
field.paint(g, game, effects.particles(), between, alpha);
}
Pitfall: record ticks, not time
It is tempting to record "UP after 3.2 seconds". But the rules never see seconds; they see turns arriving between ticks. Time also depends on the frame rate, the computer and the length of the pause the player took. The tick count does not, so the replay records ticks, and a replay recorded on a fast computer plays the same on a slow one.
Playing back is the same loop with the replay at the keys. The title screen does that, and lets the computer play the next round:
@Override
public void update(double seconds) {
if (proceed()) {
scenes.replace(new PlayScene());
return;
}
effects.update(seconds);
if (demo.phase() == Game.Phase.OVER) {
over += seconds;
// Two seconds to see the end, then the other kind of demo.
if (over > 2) startDemo(playback == null);
return;
}
for (int i = ticker.advance(seconds); i > 0 && demo.phase() == Game.Phase.PLAYING; i--) {
if (playback != null) {
for (Direction turn : playback.actionsAt(demo.ticks())) demo.turn(turn);
} else {
demo.turn(pilot.choose(demo));
}
demo.tick();
effects.react(demo.events(), false);
ticker.setInterval(demo.interval());
}
}
The test plays a short game with the keyboard, hands the replay to the title screen, and checks that the replayed snake ends in exactly the same cells, after exactly as many ticks:
@Test
void theEndHandsOverTheGameAndTheTitleReplaysIt() {
tap(KeyEvent.VK_ENTER);
SnakeGame.PlayScene play = assertInstanceOf(SnakeGame.PlayScene.class, game.top());
// A few turns, then straight into the wall.
tap(KeyEvent.VK_DOWN);
seconds(0.5);
tap(KeyEvent.VK_RIGHT);
seconds(0.4);
tap(KeyEvent.VK_UP);
seconds(10);
Game played = play.game();
assertEquals(Game.Phase.OVER, played.phase());
assertEquals(List.of(new GameResult(played.score(), played.length(), false)), results);
Replay<Direction> replay = replays.getFirst();
assertEquals(77, replay.seed());
assertEquals(3, replay.entries().size());
game.setBest(new ReplayFile.Best(replay, played.score()));
game.showTitle();
SnakeGame.TitleScene title = assertInstanceOf(SnakeGame.TitleScene.class, game.top());
assertTrue(title.replaying());
while (title.demo().phase() == Game.Phase.PLAYING) steps(1);
assertEquals(played.body(), title.demo().body(), "the same game, to the last cell");
assertEquals(played.ticks(), title.demo().ticks());
}
A whole game played by the computer, recorded and replayed, gives the same points too:
/** Plays a whole game with the computer at the keys and records every accepted turn. */
private static Replay<Direction> play(long seed, List<Integer> scores) {
Game game = new Game(seed);
Replay<Direction> replay = new Replay<>(Direction.class, seed);
Autopilot pilot = new Autopilot();
while (game.phase() == Game.Phase.PLAYING && game.ticks() < 20_000) {
Direction turn = pilot.choose(game);
if (game.turn(turn)) replay.record(game.ticks(), turn);
game.tick();
}
scores.add(game.score());
return replay;
}
@Test
void theSameSeedAndTheSameTurnsPlayTheSameGame() {
List<Integer> scores = new ArrayList<>();
Replay<Direction> replay = play(2026, scores);
// Play it again from the replay alone: same apples, same crash, same points.
Game again = new Game(replay.seed());
Replay<Direction>.Playback playback = replay.playback();
while (again.phase() == Game.Phase.PLAYING && again.ticks() < 20_000) {
for (Direction turn : playback.actionsAt(again.ticks())) again.turn(turn);
again.tick();
}
assertEquals(scores.getFirst(), again.score());
assertTrue(again.score() > 100, "a real game, not a crash at the start: " + again.score());
}
Replays as text¶
A replay is saved as text, which a person can read and a test can write:
public String toText() {
StringBuilder text = new StringBuilder("seed ").append(seed).append('\n');
for (Entry<A> entry : entries) text.append(entry.tick()).append(' ').append(entry.action().name()).append('\n');
return text.toString();
}
/** Reads {@link #toText()}; empty lines and lines starting with {@code #} are ignored. */
public static <A extends Enum<A>> Replay<A> parse(Class<A> actions, String text) {
Replay<A> replay = null;
int number = 0;
for (String line : text.split("\\R")) {
number++;
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] parts = line.split("\\s+");
try {
if (replay == null) {
if (parts.length != 2 || !parts[0].equals("seed")) throw new IllegalArgumentException("no seed");
replay = new Replay<>(actions, Long.parseLong(parts[1]));
} else {
if (parts.length != 2) throw new IllegalArgumentException("expected: tick action");
replay.record(Long.parseLong(parts[0]), Enum.valueOf(actions, parts[1]));
}
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("line " + number + ": " + e.getMessage(), e);
}
}
if (replay == null) throw new IllegalArgumentException("no seed");
return replay;
}
@Test
void actionsComeBackAtTheirTicks() {
Replay<Turn> replay = new Replay<>(Turn.class, 42);
replay.record(3, Turn.UP);
replay.record(3, Turn.LEFT);
replay.record(10, Turn.DOWN);
Replay<Turn>.Playback playback = replay.playback();
assertEquals(List.of(), playback.actionsAt(0));
assertEquals(List.of(Turn.UP, Turn.LEFT), playback.actionsAt(3));
assertEquals(List.of(), playback.actionsAt(4));
assertFalse(playback.finished());
assertEquals(List.of(Turn.DOWN), playback.actionsAt(10));
assertTrue(playback.finished());
}
@Test
void aReplayIsText() {
Replay<Turn> replay = Replay.parse(Turn.class, """
# the best game so far
seed 42
3 UP
11 LEFT
""");
assertEquals(42, replay.seed());
assertEquals(List.of(new Replay.Entry<>(3, Turn.UP), new Replay.Entry<>(11, Turn.LEFT)), replay.entries());
assertEquals(replay, Replay.parse(Turn.class, replay.toText()));
}
A good game of a hundred apples is a seed and a few hundred lines. Snake keeps the best one next to the high scores, with its points in a comment line, and writes it to a temporary file first, so a crash never leaves half a replay:
public final class ReplayFile {
private static final Logger LOG = LoggerFactory.getLogger(ReplayFile.class);
private static final String SCORE = "# score ";
/** A replay and the points it reached. */
public record Best(Replay<Direction> replay, int score) {
}
private final Path file;
public ReplayFile(Path file) {
this.file = file;
}
/** The best game, or empty if there is none yet or the file cannot be read. */
public Optional<Best> load() {
if (!Files.exists(file)) return Optional.empty();
try {
String text = Files.readString(file, StandardCharsets.UTF_8);
String first = text.lines().findFirst().orElse("");
int score = first.startsWith(SCORE) ? Integer.parseInt(first.substring(SCORE.length()).strip()) : 0;
return Optional.of(new Best(Replay.parse(Direction.class, text), score));
} catch (IOException | IllegalArgumentException e) {
LOG.warn("Cannot read the replay {}: {}", file, e.getMessage());
return Optional.empty();
}
}
/** Keeps {@code best}, replacing the file in one step, so a crash never leaves half a replay. */
public void save(Best best) {
try {
Files.createDirectories(file.getParent());
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
Files.writeString(temp, SCORE + best.score() + "\n" + best.replay().toText(), StandardCharsets.UTF_8);
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
LOG.warn("Cannot save the replay {}: {}", file, e.getMessage());
}
}
}
The presenter decides what is kept, on the Swing thread, when a game ends:
/** A game ended: the best one so far becomes the title screen's replay, a good score enters the table. */
void gameOver(GameResult result, Replay<Direction> replay) {
if (result.score() > bestScore) {
bestScore = result.score();
ReplayFile.Best best = new ReplayFile.Best(replay, result.score());
replays.save(best);
arena.setBest(best);
}
if (result.score() <= 0 || !scores.qualifies(TABLE, result.score())) return;
String message = msg.format(result.full() ? "scores.full" : "scores.reached", result.score(), result.length());
view.askName(message).ifPresent(name -> {
int place = scores.add(TABLE, new Score(name, result.score(), now.get()));
view.showScores(place);
});
}
Replays find bugs
A player reports that the snake died "for no reason". With a replay, you do not have to believe or doubt them: load the file into a test, play it to the end and look at the last tick. Many games keep the replay of every game for exactly that reason.
Step 6: The computer player¶
The title screen's computer player is simple. Before each tick it looks at the three cells the head can move to, drops those that crash, and takes the one nearest the apple:
public Direction choose(Game game) {
Cell target = game.golden() != null ? game.golden() : game.apple();
Direction best = game.heading();
int bestDistance = Integer.MAX_VALUE;
int bestRoom = -1;
for (Direction direction : Direction.values()) {
if (direction == game.heading().opposite()) continue;
Cell next = game.head().step(direction);
if (!game.isFree(next)) continue;
int room = Math.min(room(game, next), game.length());
int distance = next.distance(target);
// More room first (up to the snake's length, which is enough), then nearer the apple.
if (room > bestRoom || (room == bestRoom && distance < bestDistance)) {
best = direction;
bestRoom = room;
bestDistance = distance;
}
}
return best;
}
Nearest the apple alone is not enough. A long snake heading for an apple can turn into a pocket enclosed by its own body, with no way out. So the autopilot first counts how much room each move leaves, with the flood fill from Mines: start at the cell, visit every free neighbour, and their free neighbours, until the snake's length is reached. That is enough room to survive until the tail clears the way. A move with more room wins; among equal ones, the one nearer the apple.
/** Ten minutes of the title screen at the fastest pace, in a fraction of a second. */
@Test
void theComputerPlaysLongGames() {
long ticks = 0;
int games = 0;
int longest = 0;
for (long seed = 1; ticks < 10 * 60 / Field.MIN_INTERVAL; seed++) {
Game game = new Game(seed);
Autopilot pilot = new Autopilot();
while (game.phase() == Game.Phase.PLAYING) {
game.turn(pilot.choose(game));
game.tick();
}
ticks += game.ticks();
games++;
longest = Math.max(longest, game.length());
}
assertTrue(longest >= 60, "longest snake " + longest);
assertTrue(games <= 10, games + " games in ten minutes");
}
Ten minutes of play at the highest pace are four games, the longest snake 106 cells long, and the test needs a fraction of a second for them. The computer still dies, because counting room is not planning. In Maze, the ghosts will search their way properly, with breadth-first search, the flood fill's orderly sibling.
Step 7: Putting it together¶
The rest follows Bricks. The events become sounds and particles:
/** Shows and, if {@code audible}, plays what happened in the last tick. */
void react(List<Event> events, boolean audible) {
for (Event event : events) {
switch (event) {
case Event.Ate(Cell cell, boolean golden) -> {
burst(cell, golden ? GOLD : RED, golden ? 50 : 20, 120);
if (audible) mixer.play(golden ? sounds.golden : sounds.apple);
}
case Event.GoldenAppeared appeared -> {
burst(appeared.cell(), WHITE, 12, 60);
if (audible) mixer.play(sounds.goldenAppears);
}
case Event.GoldenGone gone -> {
if (audible) mixer.play(sounds.goldenGone);
}
case Event.Crashed(Cell cell) -> {
burst(cell, BLUE, 60, 180);
if (audible) mixer.play(sounds.crash);
}
case Event.GameOver over -> {
if (audible) mixer.play(sounds.gameOver);
}
}
}
}
final class SoundBank {
/** A soft, rising gulp: a triangle wave has no hard edges, unlike the square blips of Bricks. */
final Sound apple = Sound.tone(Wave.TRIANGLE, 500, 900, 0.09, 0.3);
/** E, G, high E: a little fanfare. */
final Sound golden = note(659, 0.06).then(note(784, 0.06)).then(note(1319, 0.15));
final Sound goldenAppears = Sound.tone(Wave.TRIANGLE, 1200, 1600, 0.08, 0.2);
final Sound goldenGone = Sound.tone(Wave.TRIANGLE, 900, 500, 0.15, 0.15);
final Sound crash = Sound.tone(Wave.NOISE, 0, 0, 0.2, 0.2);
/** G, E, C, falling. */
final Sound gameOver = note(392, 0.2).then(note(330, 0.2)).then(Sound.tone(Wave.TRIANGLE, 262, 250, 0.5, 0.3));
private static Sound note(double hz, double seconds) {
return Sound.tone(Wave.TRIANGLE, hz, hz, seconds, 0.3);
}
}
The loop starts the same way as in Bricks, at 120 steps and 60 frames a second; only the scenes inside it use the ticker:
/** Starts the loop; the canvas draws the scenes' worlds and then their overlays. */
public void start(GameCanvas canvas) {
loop = new GameLoop(UPDATES_PER_SECOND, FRAMES_PER_SECOND, this::update, canvas::render);
loop.start();
}
All pictures are sprites drawn in code: the lawn and the wall as one background, the apples, a segment, and the head in four directions. Drawn once per screen size, they cost nothing to copy, so full screen (F11), the render modes, and the frame-time display (F12) work just as in Bricks, from the same engine classes. The mouse starts a game and continues after a pause; steering stays with the keys, as in the original.

What moved into the engine¶
| From Snake | Engine package | Why |
|---|---|---|
| A tick for the rules, with a changing pace | eu.amadeos.gameengine.loop.Ticker |
Rules that move in ticks: Snake, a falling piece, a turn-based opponent |
| Random numbers that repeat | eu.amadeos.gameengine.random.SeededRandom |
Tests of whole games, games of the day, replays; effects apart from the rules |
| Replays | eu.amadeos.gameengine.replay.Replay |
A whole game as a seed and the player's actions, played back or tested |
What stays in the game: the field and its cells, the snake, the turn queue, the apples, the autopilot, the painter, the sounds and the scenes.
Exercises¶
- No walls. Let the snake leave the field on one side and come back on the other. Which method decides this, and what does the autopilot need to learn?
- Game of the day. Make the seed from today's date, so everyone who plays today gets the same apples. Add Game → Game of the Day, and keep its best score in a table of its own.
- Fast forward. Let the title screen replay the best game at double speed. Where do you change the time, and why does the game still end the same?
- Obstacles. Add levels with stone walls inside the field, as text files like the levels of Bricks. Which test makes sure the snake's starting cells are free?
- A replay for every record. Keep a replay for each entry of the high-score table, and let the high-score dialog play one back.
In the next game, Rocks, things fly at any angle and turn: vectors, rotation, a field without edges, and many short-lived bullets from a pool.