Game Engine Zero Vol 1 · Three Arcade Games
ch 08 / 24
Chapter 08

Snake

Building Snake

This chapter builds Snake on a grid. The snake moves one cell every eight ticks, remembers one turn between moves, grows when it eats food and saves the best score.

The game counts in cells, not pixels. A body move writes one new head cell and drops the tail unless the snake is growing. The ring buffer makes that move change one slot instead of copying the whole body.

Moving on a grid timer

▣ Build · stage 1 — the grid, the timer, and a head
// cmd/snake/grid.go — create
package main

// The grid is forty cells wide and twenty tall, of eight-pixel cells, and
// fills the picture below a twenty-pixel strip for the score: 40 × 8 is
// 320 across and 20 × 8 is 160 down, and cell (0, 0) sits at pixel (0, 20).
const (
	cols     = 40
	rows     = 20
	cellSize = 8
	top      = 20 // the strip above the grid, in pixels
)

// Cell is one square of the grid: column X, row Y.
type Cell struct {
	X, Y int
}

// Inside reports whether the cell is on the grid.
func (c Cell) Inside() bool {
	return c.X >= 0 && c.X < cols && c.Y >= 0 && c.Y < rows
}

// Pixel is the top-left corner of the cell on the screen.
func (c Cell) Pixel() (x, y int) {
	return c.X * cellSize, top + c.Y*cellSize
}

// Dir is a direction across the grid: one of the four below, or the zero
// Dir, which is no direction at all.
type Dir struct {
	DX, DY int
}

var (
	up    = Dir{0, -1}
	down  = Dir{0, 1}
	left  = Dir{-1, 0}
	right = Dir{1, 0}
)

// Step is the cell one step from c in direction d.
func (c Cell) Step(d Dir) Cell {
	return Cell{c.X + d.DX, c.Y + d.DY}
}
// cmd/snake/game.go — create
package main

// The snake moves one cell every eight ticks: at sixty ticks a second,
// seven and a half cells a second.
const startInterval = 8

// Snake is the state of one game: the head, its heading and its timer,
// advanced one tick at a time by Step.
type Snake struct {
	Head     Cell // the cell the head is on
	Heading  Dir  // the way the next move goes
	Interval int  // ticks between moves
	Wait     int  // ticks until the next move
}

// newSnake returns a game at tick zero: the head in the middle of the
// grid, heading right, the first move eight ticks away.
func newSnake() *Snake {
	return &Snake{Head: Cell{cols / 2, rows / 2}, Heading: right, Interval: startInterval, Wait: startInterval}
}

// Step advances the game by one tick: the timer counts down, and the head
// moves when it fires.
func (g *Snake) Step(k keys) {
	g.Wait--
	if g.Wait > 0 {
		return
	}
	g.Wait = g.Interval
	g.move()
}

// move steps the head one cell the way it is heading.
func (g *Snake) move() {
	g.Head = g.Head.Step(g.Heading)
}
// cmd/snake/main.go — create
package main

import (
	"image/color"
	"log"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/vector"
)

const (
	screenW = 320
	screenH = 180
)

var (
	courtColor = color.RGBA{R: 16, G: 20, B: 28, A: 255}
	lineColor  = color.RGBA{R: 232, G: 232, B: 232, A: 255}
	dimColor   = color.RGBA{R: 60, G: 66, B: 80, A: 255}
	bodyColor  = color.RGBA{R: 120, G: 200, B: 120, A: 255}
	foodColor  = color.RGBA{R: 232, G: 120, B: 80, A: 255}
	bandColor  = color.RGBA{R: 0, G: 0, B: 0, A: 160}
)

// keys is what the player is doing this tick: the four arrows and R,
// held or not.
type keys struct {
	up, down, left, right, r bool
}

// readKeys asks Ebitengine about the five keys, once a tick.
func readKeys() keys {
	return keys{
		up:    ebiten.IsKeyPressed(ebiten.KeyArrowUp),
		down:  ebiten.IsKeyPressed(ebiten.KeyArrowDown),
		left:  ebiten.IsKeyPressed(ebiten.KeyArrowLeft),
		right: ebiten.IsKeyPressed(ebiten.KeyArrowRight),
		r:     ebiten.IsKeyPressed(ebiten.KeyR),
	}
}

// Game is Snake's window: the snake, and everything drawn.
type Game struct {
	snake *Snake
}

// newGame builds the snake.
func newGame() *Game {
	return &Game{snake: newSnake()}
}

// step advances the game one tick.
func (g *Game) step(k keys) {
	g.snake.Step(k)
}

func (g *Game) Update() error {
	g.step(readKeys())
	return nil
}

// drawCell fills a cell's square, one pixel smaller than the cell, at the
// pixel the cell works out to.
func drawCell(dst *ebiten.Image, c Cell, clr color.RGBA) {
	x, y := c.Pixel()
	vector.FillRect(dst, float32(x), float32(y), cellSize-1, cellSize-1, clr, false)
}

// drawGrid paints the background and the line under the score strip.
func drawGrid(screen *ebiten.Image) {
	screen.Fill(courtColor)
	vector.FillRect(screen, 0, top-1, screenW, 1, dimColor, false)
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawGrid(screen)
	drawCell(screen, g.snake.Head, lineColor)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenW, screenH
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Snake")
	ebiten.SetTPS(60)
	if err := ebiten.RunGame(newGame()); err != nil {
		log.Fatal(err)
	}
}
go vet ./...
go run ./cmd/snake
A dark window with a thin grey line near the top and one small white square a little right of the middle of the space below it.
The window forty ticks after stage 1 starts: five moves have carried the head from the middle of the grid, cell (20, 10), to cell (25, 10).

A white square steps to the right, one cell every eight ticks. The grid code uses Cell for positions and Dir for movement. Stepping a cell means adding the direction to it.

The four directions are values because Go constants cannot be structs. The zero Dir means no direction. Nothing becomes a pixel until Pixel converts a cell to a screen position.

The timer is two integers. Wait counts down every tick and, on the tick it reaches zero, is reset from Interval and the head moves. Eight ticks between moves is the starting tempo. The rest of the program reads the interval instead of repeating the number.

Queuing a turn

An arrow key is held for a few ticks, and a move happens on one tick in eight. If the game only read the arrow on the tick of the move, most taps would be missed; if it changed the heading the moment the arrow was seen, two quick taps between moves would leave only the second, which is the same thing. So an arrow queues a turn, the queue holds one turn, the last one wins, and the move takes it.

▣ Build · stage 2 — the queued turn, extend cmd/snake/game.go
// cmd/snake/game.go — extend
// Snake is the state of one game: the head, its heading, a queued turn and
// its timer, advanced one tick at a time by Step.
type Snake struct {
	Head     Cell // the cell the head is on
	Heading  Dir  // the way the next move goes, unless a turn is queued
	Queued   Dir  // the turn the next move takes; the zero Dir is none
	Interval int  // ticks between moves
	Wait     int  // ticks until the next move
}

// Step advances the game by one tick with the keys held during it. An
// arrow held on any tick queues a turn; the turn is taken on the tick the
// timer fires, and only the last one queued counts.
func (g *Snake) Step(k keys) {
	if d, ok := turn(k); ok {
		g.Queued = d
	}
	g.Wait--
	if g.Wait > 0 {
		return
	}
	g.Wait = g.Interval
	g.move()
}

// move takes the queued turn, if there is one, and steps the head one cell.
func (g *Snake) move() {
	if g.Queued != (Dir{}) {
		g.Heading = g.Queued
		g.Queued = Dir{}
	}
	g.Head = g.Head.Step(g.Heading)
}

// turn reads a direction off the arrow keys: the first held, in the order
// up, down, left, right, and none when no arrow is held.
func turn(k keys) (Dir, bool) {
	switch {
	case k.up:
		return up, true
	case k.down:
		return down, true
	case k.left:
		return left, true
	case k.right:
		return right, true
	}
	return Dir{}, false
}
go vet ./...
go run ./cmd/snake
The grid with the white square above and to the right of where it started.
The window 56 ticks after stage 2 starts, with Up tapped on tick 20 and Right on tick 38: the head went right three cells, up two, and right three more, to cell (25, 8).

Tap Up and the snake turns up on its next move, whenever that is; tap Up and then Right before the move and it turns right, the first tap forgotten. In the picture, Up on tick 20 was taken by the move on tick 24 and Right on tick 38 by the move on tick 40, and a tap held for a single tick is enough, because Step reads the arrows on every tick and the queue keeps what it read. Two things about the code. turn returns a second value saying whether any arrow was held, so that a tick with no arrow leaves the queue alone instead of emptying it. And a struct can be compared with != when all its fields can, so g.Queued != (Dir{}) asks whether a turn is queued; the parentheses keep Go from reading the braces as the start of a block.

Storing the body in a ring

The body is the cells the snake occupies, head first. Every move adds the new head to the front and, unless the snake is growing, drops the tail from the back, and the cells in the middle stay where they are. A slice with the head at index 0 would have to shift every cell on every move. A ring buffer does not: it is a slice with a slot for every cell of the grid, 800 of them, an index saying which slot holds the head, and a count of how many slots are in use, running backwards from the head. A move writes the new head into the slot after the current one and moves the index; the count stays the same, which means the slot the tail was in stops being counted, and is written over eight hundred moves later.

▣ Build · stage 3 — the body as a ring buffer, a new file and two extensions
// cmd/snake/body.go — create
package main

// Body is the snake's cells in a ring buffer: a slice with a slot for every
// cell of the grid, the index of the slot the head is in, and how many
// slots are in use. The cells run backwards from the head, so the tail is
// n-1 slots behind it, wrapping round the end of the slice. A move writes
// the new head into the slot after the old one; unless the snake grew, the
// count stays the same, so the tail's slot simply stops counting. Nothing
// is copied and nothing is allocated.
type Body struct {
	slots []Cell
	head  int // the slot the head is in
	n     int // slots in use, counting back from the head
}

// newBody returns a body of the cells given, head first, with a slot for
// every cell of the grid.
func newBody(cells ...Cell) Body {
	b := Body{slots: make([]Cell, cols*rows), head: len(cells) - 1, n: len(cells)}
	for i, c := range cells {
		b.slots[b.head-i] = c
	}
	return b
}

// Len is the number of cells in the body.
func (b *Body) Len() int {
	return b.n
}

// At is the i-th cell from the head: At(0) is the head, At(Len()-1) the
// tail. Its slot is head-i, wrapped into the slice.
func (b *Body) At(i int) Cell {
	return b.slots[(b.head-i+len(b.slots))%len(b.slots)]
}

// Head is the first cell.
func (b *Body) Head() Cell {
	return b.At(0)
}

// Has reports whether c is one of the body's cells.
func (b *Body) Has(c Cell) bool {
	for i := 0; i < b.n; i++ {
		if b.At(i) == c {
			return true
		}
	}
	return false
}

// Advance writes the new head into the slot after the current one. When
// the snake grows the count goes up by one and the tail stays; otherwise
// the count stays and the tail's slot is no longer counted.
func (b *Body) Advance(head Cell, grow bool) {
	b.head = (b.head + 1) % len(b.slots)
	b.slots[b.head] = head
	if grow {
		b.n++
	}
}
// cmd/snake/game.go — extend
// The snake moves one cell every eight ticks: at sixty ticks a second,
// seven and a half cells a second. It starts three cells long.
const (
	startInterval = 8
	startLength   = 3
)

// Snake is the state of one game: the snake, its heading, a queued turn
// and its timer, advanced one tick at a time by Step.
type Snake struct {
	Body     Body // the snake's cells, head first
	Heading  Dir  // the way the next move goes, unless a turn is queued
	Queued   Dir  // the turn the next move takes; the zero Dir is none
	Interval int  // ticks between moves
	Wait     int  // ticks until the next move
}

// newSnake returns a game at tick zero: three cells in the middle of the
// grid, heading right, the first move eight ticks away.
func newSnake() *Snake {
	return &Snake{Body: startBody(), Heading: right, Interval: startInterval, Wait: startInterval}
}

// move takes the queued turn, if there is one, and steps the head one
// cell; the tail follows.
func (g *Snake) move() {
	if g.Queued != (Dir{}) {
		g.Heading = g.Queued
		g.Queued = Dir{}
	}
	g.Body.Advance(g.Head().Step(g.Heading), false)
}

// startBody is three cells in the middle of the grid, heading right.
func startBody() Body {
	head := Cell{cols / 2, rows / 2}
	cells := make([]Cell, startLength)
	for i := range cells {
		cells[i] = Cell{head.X - i, head.Y}
	}
	return newBody(cells...)
}

// Head is the cell the snake's head is on.
func (g *Snake) Head() Cell {
	return g.Body.Head()
}
// cmd/snake/main.go — extend
func (g *Game) Draw(screen *ebiten.Image) {
	drawGrid(screen)
	g.drawBody(screen)
}

// drawBody draws the snake, tail first, so that the head is drawn last.
func (g *Game) drawBody(screen *ebiten.Image) {
	s := g.snake
	for i := s.Body.Len() - 1; i > 0; i-- {
		drawCell(screen, s.Body.At(i), bodyColor)
	}
	drawCell(screen, s.Head(), lineColor)
}
go vet ./...
go run ./cmd/snake
The grid with a white square and two green squares in a row to its left, above and right of the middle.
The window 56 ticks after stage 3 starts, with the same two taps as before: three cells, the head white and the body green, the tail two cells behind on the row the last turn began.

The snake is three cells now and turns as a snake does: the head goes round the corner and the body follows it cell by cell, because each cell of the body is where the head was one, two, three moves ago. That is the ring buffer read backwards from the head, and it is also why the tail is drawn first: the head is painted last so that it is on top if the two ever share a cell, which the next stages make a matter of life and death. Head is a field no longer but a method, the first cell of the body, and every use of it gains a pair of parentheses. newBody lays the starting cells backwards from the head slot so that the tail is behind the head in the ring the same way it will be after a hundred moves.

∑ Math Interlude — the slot behind the head

Start with 800 slots, the head in slot 2, and three cells in use: the head is in slot 2, the next cell in slot 1, the tail in slot 0. A move writes the new head into slot 3, and the three cells in use are now slots 3, 2 and 1; slot 0 still holds the old tail, but nothing counts it. After 797 more moves the head is in slot 799, and the next move needs a slot: (799 + 1) mod 800 is 0, the slot the first tail left, which has not been counted for 797 moves. Reading backwards wraps the same way: with the head in slot 0 and three cells in use, the cell one behind the head is in slot (0 − 1 + 800) mod 800 = 799, and the tail in 798. Adding 800 before taking the remainder keeps the number from going negative, because Go's % of a negative number is negative.

Nthe number of slots, cols × rows = 800: one for every cell the snake could ever occupy
headthe slot the head is in, 0 to N−1
nhow many slots are in use, counting back from the head: the snake's length
(head + 1) mod Nthe slot the next head goes in; N−1 wraps to 0
(head − i + N) mod Nthe slot of the cell i behind the head; the + N keeps it from going negative
A ring of slots: the head's slot, the cells counted back from it, and the slot the next move writes A row of ten boxes numbered 0 to 9, standing for the first ten of 800 slots. Slots 1, 2 and 3 are highlighted and labelled tail, then head; slot 4 is marked as where the next head is written; slot 0 is marked as no longer counted. Below, the same after the move: slots 2, 3 and 4 in use. A note gives the two formulas. BEFORE THE MOVE: head in slot 3, n = 3 0 1 2 3 4 tail head next AFTER: head in slot 4, n still 3 0 1 2 3 4 not counted head next head: (head + 1) % N cell i back: (head − i + N) % N one write, one index, no copying; the count grows only when the snake eats N = 800 slots, one per cell of the grid, so the ring can never be full
Figure 8.1 — the ring buffer across one move: the head's slot advances, the count stays, and the old tail's slot is left uncounted.

Placing food and scoring

▣ Build · stage 4 — food, growth, the score in the strip, and a sound, extend both files
// cmd/snake/game.go — extend
import "math/rand/v2"

// The snake moves one cell every eight ticks to begin with, seven and a
// half cells a second, and starts three cells long. Every fifth food takes
// a tick off the interval, down to three.
const (
	startInterval = 8
	startLength   = 3
	foodsPerSpeed = 5
	minInterval   = 3
)

// Snake is the state of one game: the snake, its timer, the food and the
// score, advanced one tick at a time by Step.
type Snake struct {
	Body     Body // the snake's cells, head first
	Heading  Dir  // the way the next move goes, unless a turn is queued
	Queued   Dir  // the turn the next move takes; the zero Dir is none
	Interval int  // ticks between moves
	Wait     int  // ticks until the next move
	Food     Cell // the one cell with food on it
	Score    int  // foods eaten this game
	Grow     int  // moves on which the tail stays put, still owed

	rng *rand.Rand // the one source of random numbers: where the food goes
}

// newSnake returns a game at tick zero: three cells in the middle of the
// grid, heading right, the first move eight ticks away, and the food drawn
// from a generator seeded with seed.
func newSnake(seed uint64) *Snake {
	g := &Snake{
		Body: startBody(), Heading: right, Interval: startInterval, Wait: startInterval,
		rng: rand.New(rand.NewPCG(seed, 0)),
	}
	g.place()
	return g
}

// Step advances the game by one tick with the keys held during it. An
// arrow held on any tick queues a turn; the turn is taken on the tick the
// timer fires, and only the last one queued counts.
func (g *Snake) Step(k keys) event {
	if d, ok := turn(k); ok {
		g.Queued = d
	}
	g.Wait--
	if g.Wait > 0 {
		return nothing
	}
	g.Wait = g.Interval
	return g.move()
}

// move takes the queued turn, if there is one, and steps the head one
// cell; a cell with food on it is eaten.
func (g *Snake) move() event {
	if g.Queued != (Dir{}) {
		g.Heading = g.Queued
		g.Queued = Dir{}
	}
	next := g.Head().Step(g.Heading)
	grow := g.Grow > 0
	if grow {
		g.Grow--
	}
	g.Body.Advance(next, grow)
	if next == g.Food {
		g.eat()
		return ate
	}
	return nothing
}

// event is what a tick did that a player would hear: nothing, or the
// snake ate.
type event uint8

const (
	nothing event = iota
	ate
)

// place puts the food on a cell the body does not hold: a column and a
// row are drawn, and drawn again while the cell is taken.
func (g *Snake) place() {
	for {
		c := Cell{g.rng.IntN(cols), g.rng.IntN(rows)}
		if !g.Body.Has(c) {
			g.Food = c
			return
		}
	}
}

// eat scores the food under the head, grows the snake on its next move,
// takes a tick off the interval every fifth food, and places the next one.
func (g *Snake) eat() {
	g.Score++
	g.Grow++
	if g.Score%foodsPerSpeed == 0 && g.Interval > minInterval {
		g.Interval--
	}
	g.place()
}
// cmd/snake/text.go — create
package main

import (
	"bytes"
	"log"
	"math"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/text/v2"
	"golang.org/x/image/font/gofont/goregular"
)

// face is the TrueType face the game's words are set in: Go Regular,
// carried inside the program.
type face struct {
	f *text.GoTextFace
}

// newFace parses the font once and returns it at the size asked for.
func newFace(size float64) *face {
	src, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF))
	if err != nil {
		log.Fatal(err)
	}
	return &face{f: &text.GoTextFace{Source: src, Size: size}}
}

// draw draws s in the line colour with its left edge on column x and its
// top on row y.
func (fc *face) draw(dst *ebiten.Image, s string, x, y float64) {
	op := &text.DrawOptions{}
	op.GeoM.Translate(x, y)
	op.ColorScale.ScaleWithColor(lineColor)
	text.Draw(dst, s, fc.f, op)
}

// drawCentred draws s with its middle on column x and its top on row y.
func (fc *face) drawCentred(dst *ebiten.Image, s string, x, y float64) {
	w, _ := text.Measure(s, fc.f, 0)
	fc.draw(dst, s, math.Floor(x-w/2), y)
}
// cmd/snake/main.go — extend
import (
	"flag"
	"image/color"
	"log"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/vector"

	"gez/internal/digits"
	"gez/internal/sound"
)

// Game is Snake's window: the snake, and everything drawn or heard.
type Game struct {
	snake *Snake
	font  *digits.Font
	face  *face
	eat   *sound.Sound
}

// newGame builds the snake from the flags and loads what the window draws
// and plays.
func newGame() (*Game, error) {
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	eat, err := sound.Load("assets/hit.wav")
	if err != nil {
		return nil, err
	}
	return &Game{snake: newSnake(*seedFlag), font: font, face: newFace(12), eat: eat}, nil
}

// step advances the game one tick and plays what it reports.
func (g *Game) step(k keys) {
	switch g.snake.Step(k) {
	case ate:
		g.eat.Play()
	}
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawGrid(screen)
	g.drawFood(screen)
	g.drawBody(screen)
	g.drawScore(screen)
}

func main() {
	flag.Parse()
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Snake")
	ebiten.SetTPS(60)
	g, err := newGame()
	if err != nil {
		log.Fatal(err)
	}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}

var seedFlag = flag.Uint64("seed", 1, "the seed the food is drawn from")

// drawFood draws the one cell with food on it.
func (g *Game) drawFood(screen *ebiten.Image) {
	drawCell(screen, g.snake.Food, foodColor)
}

// drawScore draws the name of the game and the score in the strip.
func (g *Game) drawScore(screen *ebiten.Image) {
	g.face.draw(screen, "SNAKE", 6, 3)
	g.face.draw(screen, "SCORE", 110, 3)
	g.font.Draw(screen, g.snake.Score, 156, 5, 2)
}
go vet ./...
go run ./cmd/snake
The grid with SNAKE and SCORE 6 in the strip, a nine-cell snake in the upper middle that has just turned twice, and an orange food square far to the right.
The window ten seconds after stage 4 starts, steered toward each food as it appeared: six eaten, nine cells long, and the seventh food waiting on the right.

An orange cell appears somewhere on the grid; steer the head onto it and it beeps, the score in the strip goes up by one, the snake is one cell longer after its next move, and a new food appears elsewhere. Every fifth food takes a tick off the interval: eight ticks a move to begin with, seven after the fifth food, down to three after the twenty-fifth, which is twenty cells a second and about as fast as a person can steer. The food is drawn as an orange cell before the body, so that a head arriving on it is drawn over it.

Where the food goes is the one random thing in the game, drawn from a generator the snake owns and seeded from -seed, so that the same seed gives the same sequence of foods, and place draws again while the cell it drew is under the snake. That technique is called rejection sampling: draw from the whole grid, reject what is not allowed, draw again. It is the right tool while the snake covers little of the grid, and it is the wrong tool for a snake that covers most of it, when almost every draw would be rejected; with 800 cells and a snake that dies long before it fills them, a second draw is rare and a third rarer.

Growth is a debt. Eating adds one to Grow, and the next move pays it: Advance is told to grow, the count goes up, and the tail stays where it was. The snake lengthens by one cell one move after it eats, which is when the head has moved off the food. Step now returns an event, as Pong's does, and the window plays the hit sound on ate. The strip at the top is chapter 5's two fonts, the words from the face and the score from the sheet's digits at twice their size, and text.go is Pong's with one more method, draw, for text placed by its left edge.

Ending and restarting the game

▣ Build · stage 5 — death, and another game on R, extend both files
// cmd/snake/game.go — extend
// Snake is the state of one game: the snake, its timer, the food and the
// score, advanced one tick at a time by Step.
type Snake struct {
	Body     Body // the snake's cells, head first
	Heading  Dir  // the way the next move goes, unless a turn is queued
	Queued   Dir  // the turn the next move takes; the zero Dir is none
	Interval int  // ticks between moves
	Wait     int  // ticks until the next move
	Food     Cell // the one cell with food on it
	Score    int  // foods eaten this game
	Grow     int  // moves on which the tail stays put, still owed
	Dead     bool // the head left the grid or ran into the body; R restarts

	rng *rand.Rand // the one source of random numbers: where the food goes
}

// newSnake returns a game at tick zero: three cells in the middle of the
// grid, heading right, the first move eight ticks away, and the food drawn
// from a generator seeded with seed.
func newSnake(seed uint64) *Snake {
	g := &Snake{rng: rand.New(rand.NewPCG(seed, 0))}
	g.restart()
	return g
}

// Step advances the game by one tick with the keys held during it. An
// arrow held on any tick queues a turn; the turn is taken on the tick the
// timer fires, and only the last one queued counts. A dead game waits for R.
func (g *Snake) Step(k keys) event {
	if g.Dead {
		if k.r {
			g.restart()
		}
		return nothing
	}
	if d, ok := turn(k); ok {
		g.Queued = d
	}
	g.Wait--
	if g.Wait > 0 {
		return nothing
	}
	g.Wait = g.Interval
	return g.move()
}

// move takes the queued turn, if there is one, and steps the head one
// cell. A cell off the grid or on the snake's own body is death, decided
// before anything moves; a cell with food on it is eaten.
func (g *Snake) move() event {
	if g.Queued != (Dir{}) {
		g.Heading = g.Queued
		g.Queued = Dir{}
	}
	next := g.Head().Step(g.Heading)
	if !next.Inside() || g.Body.Has(next) {
		g.Dead = true
		return died
	}
	grow := g.Grow > 0
	if grow {
		g.Grow--
	}
	g.Body.Advance(next, grow)
	if next == g.Food {
		g.eat()
		return ate
	}
	return nothing
}

// event is what a tick did that a player would hear: nothing, the snake
// ate, or the snake died.
type event uint8

const (
	nothing event = iota
	ate
	died
)

// restart begins a new game on the same grid: the body, the timer and the
// score go back to the start, and the generator carries on, so the food
// is not where it was last time.
func (g *Snake) restart() {
	g.Body = startBody()
	g.Heading, g.Queued = right, Dir{}
	g.Interval, g.Wait = startInterval, startInterval
	g.Score, g.Grow = 0, 0
	g.Dead = false
	g.place()
}
// cmd/snake/main.go — extend
// Game is Snake's window: the snake, and everything drawn or heard.
type Game struct {
	snake    *Snake
	font     *digits.Font
	face     *face
	eat, die *sound.Sound
}

// newGame builds the snake from the flags and loads what the window draws
// and plays.
func newGame() (*Game, error) {
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	eat, err := sound.Load("assets/hit.wav")
	if err != nil {
		return nil, err
	}
	die, err := sound.Load("assets/point.wav")
	if err != nil {
		return nil, err
	}
	return &Game{snake: newSnake(*seedFlag), font: font, face: newFace(12), eat: eat, die: die}, nil
}

// step advances the game one tick and plays what it reports.
func (g *Game) step(k keys) {
	switch g.snake.Step(k) {
	case ate:
		g.eat.Play()
	case died:
		g.die.Play()
	}
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawGrid(screen)
	g.drawFood(screen)
	g.drawBody(screen)
	g.drawScore(screen)
	g.drawOver(screen)
}

// drawOver draws a band across the grid while the game waits for R.
func (g *Game) drawOver(screen *ebiten.Image) {
	if !g.snake.Dead {
		return
	}
	vector.FillRect(screen, 0, 132, screenW, 22, bandColor, false)
	g.face.drawCentred(screen, "GAME OVER  -  R FOR ANOTHER", screenW/2, 136)
}
go vet ./...
go run ./cmd/snake
The grid with SCORE 28 in the strip, a long green snake folded into a tight block in the upper left with its white head boxed in, an orange food below, and a dark band reading GAME OVER - R FOR ANOTHER across the lower grid.
The end of the game that started in the last picture, 41 seconds in: 28 foods, 31 cells, a head with nowhere to go, and the band.

Run into the border or into the body and the lower tone sounds, the snake stops, and a band says so; R starts another game with the food somewhere new. Death is decided before the move, on the cell the head is about to enter, so a dead snake is drawn where it stopped and not one cell into the wall. The body test is Has, a walk along the ring from the head to the tail, thirty-one cells at the end of the game in the picture and never more than 800; the walk is eight hundred comparisons at the worst, once every three ticks, which the game does not notice. In that picture the snake was steered toward each food by the shortest path and folded itself into a block it could not leave, which is how most games of Snake end.

restart is where a new game begins, and newSnake calls it too, so that the first game and every game after start the same way, from the same lines. The generator is the one thing restart leaves alone: it carries on from wherever it was, so the food after R is not where the food was at the start. A dead game reads only R; the arrows are ignored until the snake is alive again.

⚠ Worked failure — a turn into the neck

Stage 5's Step queues any arrow as a turn. Head right, tap Left:

The grid with SCORE 0, the three-cell snake still in the middle where it started, and the GAME OVER band across the lower grid.
Half a second after stage 5 starts, with Left tapped on tick 20: the snake is dead where it stood, at cell (22, 10), with a score of nought.

The snake dies on its next move without moving. Left was queued on tick 20, the move on tick 24 took it, and the cell one step left of the head was the neck, the second cell of the body, which Has found and called death. The rule is right; the turn is wrong, and no player means it: reversing a snake into itself is never what a tap of the opposite arrow is for. It is a common way to lose, on a keyboard where Left and Right are a finger apart, and the fix is to refuse the reversal in Step, before it is queued.

▣ Build · stage 6 — the opposite of the heading is not a turn, extend both files
// cmd/snake/grid.go — extend
// Opposite is the direction that undoes d: up for down, left for right.
func (d Dir) Opposite() Dir {
	return Dir{-d.DX, -d.DY}
}
// cmd/snake/game.go — extend
// Step advances the game by one tick with the keys held during it. An
// arrow held on any tick queues a turn, unless it is the opposite of the
// way the snake is heading; the turn is taken on the tick the timer fires,
// and only the last one queued counts. A dead game waits for R.
func (g *Snake) Step(k keys) event {
	if g.Dead {
		if k.r {
			g.restart()
		}
		return nothing
	}
	if d, ok := turn(k); ok && d != g.Heading.Opposite() {
		g.Queued = d
	}
	g.Wait--
	if g.Wait > 0 {
		return nothing
	}
	g.Wait = g.Interval
	return g.move()
}
go vet ./...
go run ./cmd/snake

A tap of the opposite arrow now does nothing, and the snake carries on. The test is against the heading, the direction the snake is going, and not against the queued turn: heading right with Up queued, a tap of Left is refused, because the snake has not turned up yet and a left turn taken on the same move would still go into the neck. A tap of Down in the same situation is accepted and replaces the Up, which is the queue doing its job.

Saving the high score

A best score should outlive the window it was scored in, and the place for a small file a program keeps for its user is the directory the operating system sets aside for exactly that: os.UserConfigDir returns it, which is ~/.config on Linux, ~/Library/Application Support on macOS and AppData\Roaming on Windows, and the game keeps gez/snake.json under it. The file holds one number, as JSON, so that it can be read by eye and edited by hand.

▣ Build · stage 7 — the best score, kept, a new file and an extension of main.go
// cmd/snake/highscore.go — create
package main

import (
	"encoding/json"
	"errors"
	"io/fs"
	"os"
	"path/filepath"
)

// The high score lives in one small JSON file in the directory the
// operating system keeps a user's settings in: ~/.config/gez/snake.json
// on Linux, and the equivalent elsewhere.
type highScore struct {
	High int `json:"high"`
}

// scorePath is where the file lives.
func scorePath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, "gez", "snake.json"), nil
}

// loadHighScore reads the file. A missing file is a score of zero and no
// error: the first game ever has nothing to beat.
func loadHighScore() (int, error) {
	path, err := scorePath()
	if err != nil {
		return 0, err
	}
	b, err := os.ReadFile(path)
	if errors.Is(err, fs.ErrNotExist) {
		return 0, nil
	}
	if err != nil {
		return 0, err
	}
	var hs highScore
	if err := json.Unmarshal(b, &hs); err != nil {
		return 0, err
	}
	return hs.High, nil
}

// saveHighScore writes the file, making its directory if it is missing.
func saveHighScore(high int) error {
	path, err := scorePath()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	b, err := json.Marshal(highScore{High: high})
	if err != nil {
		return err
	}
	return os.WriteFile(path, append(b, '\n'), 0o644)
}
// cmd/snake/main.go — extend
// Game is Snake's window: the snake, the best score so far, and everything
// drawn or heard.
type Game struct {
	snake    *Snake
	high     int // the best score, this session or from the file
	font     *digits.Font
	face     *face
	eat, die *sound.Sound
}

// newGame builds the snake from the flags, reads the high score, and loads
// what the window draws and plays.
func newGame() (*Game, error) {
	high, err := loadHighScore()
	if err != nil {
		return nil, err
	}
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	eat, err := sound.Load("assets/hit.wav")
	if err != nil {
		return nil, err
	}
	die, err := sound.Load("assets/point.wav")
	if err != nil {
		return nil, err
	}
	return &Game{snake: newSnake(*seedFlag), high: high, font: font, face: newFace(12), eat: eat, die: die}, nil
}

// step advances the game one tick, plays what it reports, and keeps the
// high score when a game ends above it.
func (g *Game) step(k keys) {
	switch g.snake.Step(k) {
	case ate:
		g.eat.Play()
	case died:
		g.die.Play()
		if g.snake.Score > g.high {
			g.high = g.snake.Score
			if err := saveHighScore(g.high); err != nil {
				log.Print(err)
			}
		}
	}
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawGrid(screen)
	g.drawFood(screen)
	g.drawBody(screen)
	g.drawScore(screen)
	g.drawBest(screen)
	g.drawOver(screen)
}

// drawBest draws the best score beside the score.
func (g *Game) drawBest(screen *ebiten.Image) {
	g.face.draw(screen, "BEST", 220, 3)
	g.font.Draw(screen, g.high, 256, 5, 2)
}
go vet ./...
go run ./cmd/snake
cat ~/.config/gez/snake.json
$ cat ~/.config/gez/snake.json
{"high":28}
The same game-over picture, with BEST 28 in the strip beside SCORE 28.
The end of the same game with the best score in the strip: 28, kept in the file the moment the snake died.

The strip now reads BEST as well as SCORE, from the file at the start and from the game as it ends: on the tick the snake dies, if the score beats the best, the best is updated and written out, before the band is even drawn. The line quoted is the file after the game in the picture, on Linux. A game closed by the window button has already saved, because saving happens on death and not on exit, and a game that never dies had nothing to save.

loadHighScore treats a missing file as a score of zero and any other trouble as an error, because the first run of the game has no file and that is not a fault, while a file that exists and cannot be read is. json.Marshal turns the struct into {"high":28} using the name in the field's tag, and Unmarshal reads it back; the newline appended to the file is for the cat above. A failure to save is printed with log.Print and the game goes on, since a score that could not be written is no reason to stop playing.

Separating cells from pixels

Snake's rules use cells and whole numbers. A position is a cell, a move is one cell, and a turn is one of four directions. The timer turns ticks into moves at a tempo the game can change with one integer.

The ring buffer fits because the body changes only at its two ends. A new head is written. The tail is kept or dropped. The cells between them do not move.

The window code turns cells into pixels when it draws. The rules do not need to know how large a cell is on screen.

Checkpoint

✓ Checkpoint — what you can now do
  • Put a game on a grid of cells with a two-integer position and a two-integer direction, and move on a timer of two integers.
  • Queue a turn between moves, keep only the last one, and say why the heading and not the queue decides whether a reversal is refused.
  • Store a body in a ring buffer and give the slot of the cell four behind the head when the head is in slot 2 of 800.
  • Place food on a free cell by drawing and rejecting, and say when that stops being a good way to do it.
  • Grow a snake by owing a move, speed it up every fifth food, and end the game on the border or the body without moving into either.
  • Keep one number in a JSON file in the user's configuration directory, read it at the start, and write it on the tick it changes.
⚡ Exercises — try first, then reveal
Exercise 1 — through the wall. Make the border wrap: a head that leaves on the right comes in on the left, and the same top to bottom.

In move, after next is worked out, fold it: next.X = (next.X + cols) % cols and the same for rows, and drop !next.Inside() from the death test. The body test still applies, so the snake can still bite itself coming round; the picture shows the head appearing on the far side one move later.

Exercise 2 — how many draws. Count how many cells place draws over a whole game, and print the count when the snake dies.

A Draws field incremented inside the loop and printed from the window's step on died. A second draw happens only when the first lands under the body, which covers a few percent of the grid in a game like the one in the pictures, so the count comes out a little above the number of placements; a snake would have to fill half the grid before draws doubled.

Exercise 3 — a faster start. Run with startInterval at 4 and then at 2 and say what changes about steering, not speed.

At 4 a tap has four ticks to land before the move, and two quick taps for a U-turn usually both land in one move and only the second counts; at 2 the second tap of any pair lands after the move. The queue holds one turn, so the faster the tempo the more a player has to time taps to moves, which is the speed-up's difficulty and not the distance covered.