Create: Computing AgesBrass Docs
Cookbook: real projects

A snake game

A complete Snake with gfx: a grid, a snake made of a list of squares, arrow keys and WASD or ZQSD, a timer for the speed, random apples, a best score saved on the disk and a game over screen.

A break between two factory shifts: the classic Snake, on a Personal Computer. Eat the red apples, grow longer, go faster, and do not bite yourself or the walls.

A game is a very good exercise, because it needs everything at once: a state kept in tables, a main loop that mixes a timer and the keyboard, drawing that only touches what moved, random numbers, and a file to remember the best score. The same skeleton fits any game with a grid: Tetris, a maze, a mine sweeper.

What you need

  • A Personal Computer, or a Modern Computer for a bigger board. The game uses gfx and colours. The board is computed from the size of the screen: 38 × 19 squares on a Personal Computer, 47 × 24 on a Modern Computer, 29 × 11 on a Microcontroller.
  • Full rotation speed (256 RPM): at full speed a Personal Computer runs 1,200 instructions per tick, and one move of the snake takes a few hundred.
  • You play in the terminal (right-click the computer): keys only reach a program from the terminal window. A monitor wall shows the game in big for the spectators.

Save the program as snake and start it by typing its name.

How it works

1. The board

The board is a grid of squares of 8 × 8 pixels, under a score bar 16 pixels high. Each square is drawn 7 × 7, so a one-pixel gap separates the squares and the grid shows by itself.

Brass
local CELL = 8
local TOP = 16
local size = gfx.size()
local W, H = size.w, size.h
local COLS = (W - 2) // CELL
local ROWS = (H - TOP - 2) // CELL
local X0 = (W - COLS * CELL) // 2 + 1
local Y0 = TOP + 1

local function draw_cell(x, y, color)
  gfx.rect(X0 + (x - 1) * CELL, Y0 + (y - 1) * CELL, CELL - 1, CELL - 1, color, true)
end

Square (1, 1) is the top left one. Everything else in the program talks in squares; only draw_cell knows about pixels.

2. The snake: a list of squares

The snake is a list of {x =, y =} tables, the head first. A second table, occupied, marks the squares the snake covers, with a number made of both coordinates as the key (y * 1000 + x). Asking occupied[key(x, y)] answers "is the snake here?" at once, where walking the whole list would cost more and more as the snake grows.

Brass
local snake = {}       -- squares {x =, y =}, the head first
local occupied = {}    -- occupied[key(x, y)] = true where the snake is

3. One step

At each tick of the game, the snake moves one square. Instead of moving every square of its body, the program adds a new head in front and removes the last square of the tail: the squares in between do not change. Only three squares are redrawn: the tail erased, the old head painted as body, the new head painted bright.

Brass
local function step()
  dir = next_dir
  local head = snake[1]
  local nx, ny = head.x + dir.x, head.y + dir.y
  local eats = food ~= nil and nx == food.x and ny == food.y
  if not eats then
    local tail = table.remove(snake)
    occupied[key(tail.x, tail.y)] = nil
    draw_cell(tail.x, tail.y, "black")
  end
  if nx < 1 or nx > COLS or ny < 1 or ny > ROWS or occupied[key(nx, ny)] then
    game_over()
    return
  end
  draw_cell(head.x, head.y, "green")
  table.insert(snake, 1, {x = nx, y = ny})
  occupied[key(nx, ny)] = true
  draw_cell(nx, ny, "lime")
  if eats then
    score = score + 1
    draw_score()
    if not place_food() then game_over() end
  end
end

The order matters. The tail is removed before the collision test: the head may move into the square the tail is just leaving, which is allowed. When the snake eats, the tail stays, and the snake is one square longer. table.remove(snake) without a position takes the last element; table.insert(snake, 1, ...) puts the new head in front.

4. The keys

Arrows arrive as key events, with e.key set to "up", "down", "left" or "right". Letters arrive as char events, with the typed character in e.char. Both keyboard layouts work without any setting: W and Z go up, A and Q go left, S goes down and D right, and no letter means two things. string.lower makes Shift and Caps Lock harmless.

Brass
local LETTERS = {w = "up", z = "up", s = "down", a = "left", q = "left", d = "right"}

A key does not move the snake: it only chooses the next direction. steer refuses a U-turn, which would make the head bite its own neck. It compares with dir, the direction of the last step, not with the last key: otherwise pressing up then left quickly, between two steps, while going right, would still turn the snake back on itself.

Brass
local function steer(name)
  local d = DIRS[name]
  if d and not (d.x == -dir.x and d.y == -dir.y) then next_dir = d end
end

5. The pace: a timer

The game advances on a timer, not on the keys: os.start_timer(delay()) after each step. The delay shrinks with the score, from 0.2 s to 0.05 s. Timers count in ticks (a twentieth of a second, rounded up), so 0.05 s, one tick, is the fastest possible pace.

The main loop checks that the timer event is the one it is waiting for (e.id == timer). After a pause or a new game, an older timer may still fire: its id is not the current one, and it is ignored.

6. Apples, best score, game over

place_food draws random squares with math.random until it finds one the snake does not cover. When the snake fills the whole board, there is no free square left: the game ends, and you have won.

The best score lives in a file, snake.best, read at start and written when a game beats it. Both go through pcall: without a disk, or with a file that does not contain a number (tonumber gives nil), the game still runs, starting from 0.

game_over draws a box over the board with draw_box. Then Space or Enter starts a new game, which redraws the whole board once. P pauses and resumes at any time.

The whole program

snake
-- Snake: eat the red apples, do not bite yourself or the walls.
-- Arrows, WASD or ZQSD to steer, P to pause, Ctrl+T to quit.

-- 1. The board, computed from the size of the screen
local CELL = 8                               -- pixels per square
local TOP = 16                               -- the score bar above the board
local size = gfx.size()
local W, H = size.w, size.h
local COLS = (W - 2) // CELL
local ROWS = (H - TOP - 2) // CELL
local X0 = (W - COLS * CELL) // 2 + 1        -- first pixel of the board
local Y0 = TOP + 1
local BEST_FILE = "snake.best"

local DIRS = {
  up = {x = 0, y = -1}, down = {x = 0, y = 1},
  left = {x = -1, y = 0}, right = {x = 1, y = 0},
}
local LETTERS = {w = "up", z = "up", s = "down", a = "left", q = "left", d = "right"}

-- 2. The state of a game
local snake = {}       -- squares {x =, y =}, the head first
local occupied = {}    -- occupied[key(x, y)] = true where the snake is
local dir, next_dir    -- the direction of the last step, and the one asked for the next
local food = nil
local score, best = 0, 0
local state = "play"   -- "play", "pause" or "over"
local timer = nil

local function key(x, y)
  return y * 1000 + x
end

-- 3. Drawing
local function draw_cell(x, y, color)
  gfx.rect(X0 + (x - 1) * CELL, Y0 + (y - 1) * CELL, CELL - 1, CELL - 1, color, true)
end

local function draw_score()
  gfx.rect(1, 1, W, TOP - 1, "black", true)
  gfx.text(4, 3, "SCORE " .. score, "white", 2)
  local text = "BEST " .. best
  gfx.text(W - #text * 8, 3, text, "yellow", 2)
end

local function draw_board()
  gfx.clear("black")
  gfx.rect(X0 - 1, Y0 - 1, COLS * CELL + 1, ROWS * CELL + 1, "gray")
  for i, s in ipairs(snake) do
    draw_cell(s.x, s.y, i == 1 and "lime" or "green")
  end
  if food then draw_cell(food.x, food.y, "red") end
  draw_score()
end

-- A box in the middle of the board, with lines of text
local function draw_box(lines, color)
  local bw, bh = 200, 12 + #lines * 14
  local bx, by = (W - bw) // 2, Y0 + (ROWS * CELL - bh) // 2
  gfx.rect(bx, by, bw, bh, "black", true)
  gfx.rect(bx, by, bw, bh, color, 2)
  for i, line in ipairs(lines) do
    gfx.text((W - (#line * 8 - 2)) // 2, by + 8 + (i - 1) * 14, line, i == 1 and color or "white", 2)
  end
end

-- 4. The rules
local function place_food()
  if #snake == COLS * ROWS then
    food = nil
    return false   -- no free square left: the board is full
  end
  repeat
    food = {x = math.random(1, COLS), y = math.random(1, ROWS)}
  until not occupied[key(food.x, food.y)]
  draw_cell(food.x, food.y, "red")
  return true
end

local function delay()
  return math.max(0.05, 0.2 - score * 0.005)   -- a little faster with each apple
end

local function new_game()
  snake, occupied, score, food = {}, {}, 0, nil
  local cx, cy = COLS // 2, ROWS // 2
  for i = 0, 2 do
    table.insert(snake, {x = cx - i, y = cy})
    occupied[key(cx - i, cy)] = true
  end
  dir, next_dir = DIRS.right, DIRS.right
  draw_board()
  place_food()
  state = "play"
  timer = os.start_timer(delay())
end

local function game_over()
  state = "over"
  local lines = {"GAME OVER", "SCORE " .. score}
  if score > best then
    best = score
    pcall(fs.write, BEST_FILE, tostring(best))   -- no disk: the record is just not kept
    table.insert(lines, "NEW BEST!")
  end
  table.insert(lines, "SPACE: AGAIN")
  draw_score()
  draw_box(lines, "red")
end

-- No U-turn: the snake would bite its own neck
local function steer(name)
  local d = DIRS[name]
  if d and not (d.x == -dir.x and d.y == -dir.y) then next_dir = d end
end

-- One move: the head goes forward, the tail follows unless an apple was eaten
local function step()
  dir = next_dir
  local head = snake[1]
  local nx, ny = head.x + dir.x, head.y + dir.y
  local eats = food ~= nil and nx == food.x and ny == food.y
  if not eats then
    local tail = table.remove(snake)
    occupied[key(tail.x, tail.y)] = nil
    draw_cell(tail.x, tail.y, "black")
  end
  if nx < 1 or nx > COLS or ny < 1 or ny > ROWS or occupied[key(nx, ny)] then
    game_over()
    return
  end
  draw_cell(head.x, head.y, "green")
  table.insert(snake, 1, {x = nx, y = ny})
  occupied[key(nx, ny)] = true
  draw_cell(nx, ny, "lime")
  if eats then
    score = score + 1
    draw_score()
    if not place_food() then game_over() end
  end
end

local function toggle_pause()
  if state == "play" then
    state = "pause"
    draw_box({"PAUSE", "P: GO ON"}, "yellow")
  elseif state == "pause" then
    state = "play"
    draw_board()
    timer = os.start_timer(delay())
  end
end

-- 5. Main loop: the timer sets the pace, the keys only change the direction
local r = pcall(fs.read, BEST_FILE)
if r.ok then best = tonumber(r.value) or 0 end
term.clear()
new_game()
while true do
  local e = os.pull_event()
  if e.name == "timer" and e.id == timer and state == "play" then
    step()
    if state == "play" then timer = os.start_timer(delay()) end
  elseif e.name == "key" and state == "play" then
    steer(e.key)                               -- "up", "down", "left", "right"
  elseif e.name == "key" and e.key == "enter" and state == "over" then
    new_game()
  elseif e.name == "char" then
    local c = string.lower(e.char)
    if c == "p" then
      toggle_pause()
    elseif c == " " and state == "over" then
      new_game()
    elseif state == "play" then
      steer(LETTERS[c])
    end
  end
end

What it looks like

A game in progress, with a snake and an apple placed by hand, drawn by the same functions as the program:

game demo
local CELL, TOP = 8, 16
local size = gfx.size()
local W, H = size.w, size.h
local COLS = (W - 2) // CELL
local ROWS = (H - TOP - 2) // CELL
local X0 = (W - COLS * CELL) // 2 + 1
local Y0 = TOP + 1

-- a scripted state: 14 squares, the head first
local snake = {}
for _, p in ipairs({{24, 6}, {23, 6}, {22, 6}, {21, 6}, {21, 7}, {21, 8}, {21, 9}, {20, 9},
    {19, 9}, {18, 9}, {17, 9}, {17, 10}, {17, 11}, {16, 11}}) do
  table.insert(snake, {x = p[1], y = p[2]})
end
local food = {x = 30, y = 6}
local score, best = 11, 23

local function draw_cell(x, y, color)
  gfx.rect(X0 + (x - 1) * CELL, Y0 + (y - 1) * CELL, CELL - 1, CELL - 1, color, true)
end
local function draw_score()
  gfx.rect(1, 1, W, TOP - 1, "black", true)
  gfx.text(4, 3, "SCORE " .. score, "white", 2)
  local text = "BEST " .. best
  gfx.text(W - #text * 8, 3, text, "yellow", 2)
end
local function draw_board()
  gfx.clear("black")
  gfx.rect(X0 - 1, Y0 - 1, COLS * CELL + 1, ROWS * CELL + 1, "gray")
  for i, s in ipairs(snake) do
    draw_cell(s.x, s.y, i == 1 and "lime" or "green")
  end
  if food then draw_cell(food.x, food.y, "red") end
  draw_score()
end

draw_board()
Screen
Screen

And the end of a game that beat the record, with the box of game_over:

game over demo
local CELL, TOP = 8, 16
local size = gfx.size()
local W, H = size.w, size.h
local COLS = (W - 2) // CELL
local ROWS = (H - TOP - 2) // CELL
local X0 = (W - COLS * CELL) // 2 + 1
local Y0 = TOP + 1

-- 27 squares: the head went up into the top wall
local snake = {{x = 31, y = 1}, {x = 31, y = 2}, {x = 31, y = 3}}
for x = 30, 7, -1 do
  table.insert(snake, {x = x, y = 3})
end
local food = {x = 27, y = 17}
local score, best = 24, 24

local function draw_cell(x, y, color)
  gfx.rect(X0 + (x - 1) * CELL, Y0 + (y - 1) * CELL, CELL - 1, CELL - 1, color, true)
end
local function draw_score()
  gfx.rect(1, 1, W, TOP - 1, "black", true)
  gfx.text(4, 3, "SCORE " .. score, "white", 2)
  local text = "BEST " .. best
  gfx.text(W - #text * 8, 3, text, "yellow", 2)
end
local function draw_board()
  gfx.clear("black")
  gfx.rect(X0 - 1, Y0 - 1, COLS * CELL + 1, ROWS * CELL + 1, "gray")
  for i, s in ipairs(snake) do
    draw_cell(s.x, s.y, i == 1 and "lime" or "green")
  end
  if food then draw_cell(food.x, food.y, "red") end
  draw_score()
end
local function draw_box(lines, color)
  local bw, bh = 200, 12 + #lines * 14
  local bx, by = (W - bw) // 2, Y0 + (ROWS * CELL - bh) // 2
  gfx.rect(bx, by, bw, bh, "black", true)
  gfx.rect(bx, by, bw, bh, color, 2)
  for i, line in ipairs(lines) do
    gfx.text((W - (#line * 8 - 2)) // 2, by + 8 + (i - 1) * 14, line, i == 1 and color or "white", 2)
  end
end

draw_board()
draw_box({"GAME OVER", "SCORE " .. score, "NEW BEST!", "SPACE: AGAIN"}, "red")
Screen
Screen

Testing it

  1. edit snake, paste, save, then type snake. The snake starts in the middle, heading right.
  2. Steer with the arrows, then with the letters of your keyboard: both work at any time.
  3. Try to turn back on yourself (left while going right): nothing happens. Quickly press up then left, between two steps: the snake goes up and does not bite its neck, because the left was refused (it still faced right). Press left again once it goes up.
  4. Eat a few apples: the score grows and the snake speeds up.
  5. Hit a wall: GAME OVER and NEW BEST!. Quit with Ctrl+T and look at the record:

    Terminal
    > cat snake.best
    7
  6. Run snake again: BEST shows 7. Press P during a game to pause, again to resume.

Variations

  • Walls that wrap. Instead of losing at the edge, come out on the other side: nx = (nx - 1) % COLS + 1 and the same for ny, before the collision test (% never gives a negative number).
  • Obstacles. At the start of a game, mark a few squares in occupied and draw them grey: the collision test already handles them, and place_food will not put an apple there.
  • Golden apples. One apple in ten (math.random(10) == 1) is yellow and worth five points, but disappears after a few seconds: a second timer.
  • On a touch screen. Keys only come from the terminal; on a monitor, use click events instead: a click left of the head turns left, right of it turns right, using e.px and e.py.
  • Two players. The pong example shows how two computers share a game over the network.