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
gfxand 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.
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)
endSquare (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.
local snake = {} -- squares {x =, y =}, the head first
local occupied = {} -- occupied[key(x, y)] = true where the snake is3. 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.
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
endThe 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.
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.
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
end5. 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: 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
endWhat it looks like
A game in progress, with a snake and an apple placed by hand, drawn by the same functions as the program:
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()
And the end of a game that beat the record, with the box of game_over:
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")
Testing it
edit snake, paste, save, then typesnake. The snake starts in the middle, heading right.- Steer with the arrows, then with the letters of your keyboard: both work at any time.
- 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.
- Eat a few apples: the score grows and the snake speeds up.
Hit a wall: GAME OVER and NEW BEST!. Quit with Ctrl+T and look at the record:
> cat snake.best 7
- Run
snakeagain: 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 + 1and the same forny, before the collision test (%never gives a negative number). - Obstacles. At the start of a game, mark a few squares in
occupiedand draw them grey: the collision test already handles them, andplace_foodwill 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
clickevents instead: a click left of the head turns left, right of it turns right, usinge.pxande.py. - Two players. The
pongexample shows how two computers share a game over the network.