← LOGBOOK LOG-434
COMPLETE · SOFTWARE ·
RUBYRUBY2DGAME-DEVELOPMENTSNAKE-GAME

Snake Game in Ruby2D

A Snake implementation built incrementally from a grid, timed state updates, buffered input, collision rules, and a Ruby2D render pass.

Snake is a discrete simulation. The world advances in small steps rather than through continuous motion: the head enters one new grid cell, the body follows, and the game checks whether that resulting state is valid. The same structure appears in turn-based games, tile puzzles, roguelikes, and many real-time games whose movement is ultimately stored as state.

Install Ruby2D once:

gem install ruby2d

Grid Geometry

The game uses cells rather than raw pixels. CELL_SIZE is the width and height of one cell in pixels. COLS is the number of columns across the board; ROWS is the number of rows down it. Constants are Ruby names written in uppercase. They hold values that define the shape of the game rather than values that change while it runs.

require 'ruby2d'

CELL_SIZE = 24
COLS = 30
ROWS = 20
TICK_SECONDS = 0.12

set title: 'Snake',
    width: COLS * CELL_SIZE,
    height: ROWS * CELL_SIZE,
    background: '#101418',
    resizable: false

The window is 720 × 480 pixels because 30 × 24 = 720 and 20 × 24 = 480. Changing COLS, ROWS, or CELL_SIZE changes the board without changing the movement rules. TICK_SECONDS controls game speed: a tick occurs every 0.12 seconds, or a little over eight times per second.

Grid coordinates use [x, y]. The cell [0, 0] is at the upper-left. Increasing x moves right; increasing y moves down. That downward y-axis is conventional for screen coordinates.

Drawing the Board

Ruby2D draws Square and Text objects. The board is a nested array: board[y][x] identifies the visual square at column x and row y. Creating these squares once is simpler and cheaper than allocating a new visual object every tick; rendering later only changes their colours.

board = Array.new(ROWS) do |y|
  Array.new(COLS) do |x|
    Square.new(
      x: x * CELL_SIZE,
      y: y * CELL_SIZE,
      size: CELL_SIZE - 1,
      color: '#182028'
    )
  end
end

score_label = Text.new('', x: 10, y: 8, size: 22, color: 'white', z: 10)
message_label = Text.new('', x: 0, y: 0, size: 28, color: 'white', z: 10)

Array.new(ROWS) creates an array with one entry for every row. The block between do and end runs once per row; its inner Array.new(COLS) runs once per column. The block parameters y and x are the current row and column. Multiplying them by CELL_SIZE converts grid coordinates to pixel coordinates.

Game State

The snake is an ordered array of cells. Its first cell is the head and its last cell is the tail. A direction is a small vector: [1, 0] means one cell right, [-1, 0] one cell left, [0, -1] one cell up, and [0, 1] one cell down.

directions = {
  'left' => [-1, 0],
  'right' => [1, 0],
  'up' => [0, -1],
  'down' => [0, 1]
}

snake = []
direction = [1, 0]
next_direction = direction
food = nil
game_over = false
last_tick = Time.now

directions is a Ruby hash. A hash maps a key to a value, so directions['left'] returns [-1, 0]. direction is the direction used by the next game tick. next_direction stores keyboard input until that tick occurs. This separation prevents a burst of key events from moving the snake more than once in a single update.

Food and Reset

Food must never appear inside the snake. spawn_food repeatedly samples a random board cell until it finds one not occupied by the body. The lambda stores a reusable block of code; .call runs it. reset establishes every value needed for a new game in one place.

spawn_food = lambda do |snake|
  loop do
    cell = [rand(COLS), rand(ROWS)]
    return cell unless snake.include?(cell)
  end
end

reset = lambda do
  snake.replace([[15, 10], [14, 10], [13, 10]])
  direction = [1, 0]
  next_direction = direction
  food = spawn_food.call(snake)
  game_over = false
  last_tick = Time.now
end

rand(COLS) produces an integer from zero up to, but excluding, COLS. include? asks whether the snake already contains that cell. snake.replace changes the existing array rather than assigning a different array; the rest of the program therefore continues to refer to the same game-state object.

Rendering State

The render pass translates state into colours. It does not decide movement, collision, or scoring. That boundary keeps visual changes separate from game rules.

render = lambda do
  board.flatten.each { |square| square.color = '#182028' }
  snake.each { |x, y| board[y][x].color = '#58d68d' }
  board[food[1]][food[0]].color = '#e74c3c'

  score_label.text = "Score: #{snake.length - 3}"
  message_label.text = game_over ? 'Game over — press R to restart' : ''
  message_label.x = (COLS * CELL_SIZE - message_label.width) / 2
  message_label.y = (ROWS * CELL_SIZE - message_label.height) / 2
end

reset.call
render.call

flatten converts the nested board array into one list of squares so every cell can be reset to the background colour. snake.each { |x, y| ... } iterates through body cells and destructures each [x, y] coordinate into two variables. The expression snake.length - 3 derives score from length: a three-segment starting snake has a score of zero.

Buffered Input

Ruby2D calls this block whenever a key is pressed. The handler records the requested direction but does not move the snake. Restarting belongs here because it is an immediate response to an input event.

on :key_down do |event|
  if game_over && event.key == 'r'
    reset.call
    render.call
    next
  end

  candidate = directions[event.key]
  next unless candidate

  opposite = [-direction[0], -direction[1]]
  next_direction = candidate unless candidate == opposite
end

event.key contains strings such as 'left' and 'r'. next ends the current event-handler call. The opposite vector negates both direction components. Rejecting it prevents an immediate U-turn into the segment directly behind the head.

The Tick

The update block runs frequently, but the first guard allows the game state to change only after TICK_SECONDS have elapsed. A valid tick has five phases: apply buffered input, calculate the new head, test collisions, commit the movement, and render the result.

update do
  next if game_over || Time.now - last_tick < TICK_SECONDS

  last_tick = Time.now
  direction = next_direction
  head_x, head_y = snake.first
  new_head = [head_x + direction[0], head_y + direction[1]]
  eating = new_head == food

  hit_wall = new_head[0] < 0 || new_head[0] >= COLS ||
             new_head[1] < 0 || new_head[1] >= ROWS
  body_to_check = eating ? snake : snake[0...-1]
  hit_self = body_to_check.include?(new_head)

  if hit_wall || hit_self
    game_over = true
  else
    snake.unshift(new_head)
    if eating
      food = spawn_food.call(snake)
    else
      snake.pop
    end
  end

  render.call
end

show

snake.first returns the head. Ruby permits head_x, head_y = snake.first, which assigns the two coordinate values in one expression. The new head adds the direction vector to that position.

The wall check is a bounds check. Valid x values run from 0 to COLS - 1; valid y values run from 0 to ROWS - 1. The self-collision check has one important exception. On an ordinary move, the tail disappears at the end of the tick, so entering the tail’s current cell is valid. snake[0...-1] excludes that tail. When eating, the tail remains and all body cells are solid.

unshift commits the new head. pop removes the tail when no food was eaten. Eating skips pop, which is the entire growth mechanic.

Invariants

The game is stable because each tick preserves a small set of invariants:

  • Every snake segment is a two-integer grid coordinate.
  • Every segment is inside the board after a non-terminal tick.
  • No food cell is occupied by the snake.
  • The first snake segment is the head.
  • direction is never the immediate opposite of the previous direction.

These constraints are more useful than any particular drawing API. A platformer replaces the grid-cell move with velocity and collision resolution; a shooter replaces the body array with entities and projectiles. Both still depend on an explicit state, an update rule, and conditions that the update is not allowed to violate.

What Next

  • Reduce TICK_SECONDS after every five food items to create a difficulty curve.
  • Add a second food type with a lifetime and a different score value.
  • Add an explicit paused state and a key that switches between paused and running.
  • Persist a high score in a text file.
  • Replace the grid rendering with sprites while retaining the state and tick logic.