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
// 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 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.
// 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
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.
// 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 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.
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.
Placing food and scoring
// 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
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
// 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
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.
Stage 5's Step queues any arrow as a turn. Head right, tap Left:
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.
// 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.
// 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 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
- 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.
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.