← LOGBOOK LOG-440
COMPLETE · SOFTWARE ·
ODINRAYLIBGAME-DEVELOPMENTSNAKE-GAMEGRIDSCOLLISION

Snake in Odin + raylib

A grid-based Snake implementation in Odin and raylib: ordered dynamic-array state, buffered turns, fixed-interval movement, collision transactions, food spawning, and immediate-mode rendering.

The snake is an ordered sequence of occupied grid cells: its first cell is the head, and its final cell is the tail. Each move adds one adjacent cell at the front of that sequence and, unless food was reached, removes one from the end. Rendering may occur sixty times per second while this state changes every 0.15 seconds. The separation makes movement independent of display refresh rate and reduces the rules to operations on integer grid coordinates.

The implementation occupies one main.odin package. The fragments below are source ordered and compose into a runnable program with odin run ..

Constants and State

package main

import "core:math/rand"
import rl "vendor:raylib"

CELL_SIZE :: 24
COLS :: 30
ROWS :: 20
TICK_SECONDS :: 0.15

Cell :: [2]i32
Direction :: enum { Up, Down, Left, Right }

Game :: struct {
    snake: [dynamic]Cell,
    food: Cell,
    dir, next_dir: Direction,
    timer: f32,
    score: i32,
    game_over: bool,
}

The board is 30 × 20 cells, and each cell is 24 × 24 pixels; the raylib window is therefore 720 × 480 pixels. Cell is a fixed pair of i32 values. Its .x and .y field aliases are grid coordinates, never pixel coordinates.

snake is an ordered dynamic array. Index zero is the head, and the final index is the tail. food is also a grid coordinate. dir records the direction that produced the last committed move; next_dir stores a turn request. timer holds elapsed rendering time that has not yet produced a simulation move.

The update maintains these invariants:

  • Every snake segment lies within the board after a non-terminal tick.
  • Every segment is a Cell, and the head is snake[0].
  • food does not overlap any snake segment.
  • dir cannot be the immediate opposite of the direction used on the preceding tick.
  • game_over prevents further state transitions until reset.

Spawning and Reset

spawn_food :: proc(game: ^Game) {
    for {
        candidate := Cell{rand.int31_max(COLS), rand.int31_max(ROWS)}
        occupied := false
        for segment in game.snake {
            if candidate == segment {
                occupied = true
                break
            }
        }
        if !occupied {
            game.food = candidate
            return
        }
    }
}

reset :: proc(game: ^Game) {
    clear(&game.snake)
    append(&game.snake, Cell{15, 10})
    append(&game.snake, Cell{14, 10})
    append(&game.snake, Cell{13, 10})
    game.dir = .Right
    game.next_dir = .Right
    game.timer = 0
    game.score = 0
    game.game_over = false
    spawn_food(game)
}

spawn_food is rejection sampling: it selects a random cell and rejects it when that cell is already occupied. rand.int31_max(COLS) returns an integer from zero through COLS - 1, so the candidate begins inside the board. The loop terminates quickly while the board has substantial free space.

The algorithm has a real boundary condition. A full board has no acceptable candidate, so the loop cannot terminate. A complete end condition either tests len(game.snake) == COLS * ROWS before spawning or builds a list of unoccupied cells and samples that list. The small implementation leaves the condition outside its active state space, but the condition is part of the model.

clear sets the dynamic array length to zero without releasing its backing allocation. Resetting can therefore reuse capacity across games. delete(game.snake) in main releases that allocation when the application exits.

Input and Candidate Movement

handle_input :: proc(game: ^Game) {
    if rl.IsKeyPressed(.UP) && game.dir != .Down do game.next_dir = .Up
    if rl.IsKeyPressed(.DOWN) && game.dir != .Up do game.next_dir = .Down
    if rl.IsKeyPressed(.LEFT) && game.dir != .Right do game.next_dir = .Left
    if rl.IsKeyPressed(.RIGHT) && game.dir != .Left do game.next_dir = .Right
}

next_cell :: proc(cell: Cell, dir: Direction) -> Cell {
    next := cell
    switch dir {
    case .Up: next.y -= 1
    case .Down: next.y += 1
    case .Left: next.x -= 1
    case .Right: next.x += 1
    }
    return next
}

IsKeyPressed is edge-triggered: it reports the frame on which a key moves from up to down. The handler records intent but does not move the snake. The immediate-opposite check prevents a U-turn into the segment directly behind the head. Comparing against dir, rather than next_dir, makes the direction committed exactly once at the next simulation tick.

next_cell is pure with respect to game state. It copies the old head, adds one grid unit in one cardinal direction, and returns the candidate. No segment has moved while the legality of that candidate remains unknown.

One Simulation Tick

update :: proc(game: ^Game, dt: f32) {
    if game.game_over do return

    game.timer += dt
    if game.timer < TICK_SECONDS do return
    game.timer = 0
    game.dir = game.next_dir

    head := next_cell(game.snake[0], game.dir)
    hit_wall := head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS
    hit_self := false
    for segment in game.snake {
        if head == segment do hit_self = true
    }
    if hit_wall || hit_self {
        game.game_over = true
        return
    }

    inject_at(&game.snake, 0, head)
    if head == game.food {
        game.score += 10
        spawn_food(game)
    } else {
        pop(&game.snake)
    }
}

The timer converts variable frame durations into a regular simulation tick. GetFrameTime returns seconds since the previous rendered frame; timer += dt accumulates those intervals. A frame that arrives before TICK_SECONDS returns without changing the state. A frame that crosses the threshold commits one cell move.

Resetting timer to zero discards excess time from an unusually slow frame. This avoids a multi-move catch-up burst, at the cost of small timing drift. A fixed-timestep simulation that needs greater temporal accuracy subtracts TICK_SECONDS and iterates while sufficient time remains, usually with a maximum number of updates per frame.

Valid board cells satisfy 0 <= x < COLS and 0 <= y < ROWS. The wall test establishes that bound before array mutation. Structural equality on Cell makes head == segment a collision test over both coordinate components.

inject_at inserts the candidate at index zero and shifts the prior head and body toward the tail. A normal move then pops the final segment, preserving length. Eating omits pop, so retaining the tail is the entire growth mechanism. Collision occurs before the tail is removed, which makes the tail solid during an ordinary move. This is a valid Snake rule, distinct from variants that permit a head to enter the cell the tail vacates during the same tick.

Draw the Current State

draw :: proc(game: Game) {
    rl.ClearBackground(rl.Color{16, 30, 24, 255})

    for x: i32 = 0; x <= COLS; x += 1 do
        rl.DrawLine(x * CELL_SIZE, 0, x * CELL_SIZE, ROWS * CELL_SIZE, rl.Color{40, 64, 52, 255})
    for y: i32 = 0; y <= ROWS; y += 1 do
        rl.DrawLine(0, y * CELL_SIZE, COLS * CELL_SIZE, y * CELL_SIZE, rl.Color{40, 64, 52, 255})

    rl.DrawRectangle(game.food.x * CELL_SIZE + 4, game.food.y * CELL_SIZE + 4, CELL_SIZE - 8, CELL_SIZE - 8, rl.GOLD)
    for segment, i in game.snake {
        color := rl.DARKGREEN
        if i == 0 do color = rl.GREEN
        rl.DrawRectangle(segment.x * CELL_SIZE + 2, segment.y * CELL_SIZE + 2, CELL_SIZE - 4, CELL_SIZE - 4, color)
    }
    rl.DrawText(rl.TextFormat("Score: %i", game.score), 12, 10, 20, rl.RAYWHITE)
    if game.game_over do rl.DrawText("Game over — press R", 220, 220, 24, rl.RAYWHITE)
}

Drawing converts cells to pixels with cell.x * CELL_SIZE and cell.y * CELL_SIZE. The grid counters are explicitly i32 because raylib’s DrawLine accepts 32-bit coordinates; a := 0 counter would infer Odin’s platform-sized int instead.

The render procedure receives Game by value and does not alter it. For its dynamic-array field, that value copy carries a reference to the same backing data; the no-mutation boundary is therefore an API convention, not deep immutability. The procedure clears the framebuffer and draws grid, food, body, head, score, and game-over text in layer order. raylib retains none of these commands after EndDrawing; the state is the source of every frame.

Assemble the Runtime

main :: proc() {
    width := i32(COLS * CELL_SIZE)
    height := i32(ROWS * CELL_SIZE)
    rl.InitWindow(width, height, "Snake")
    defer rl.CloseWindow()
    rl.SetTargetFPS(60)

    game := Game{}
    defer delete(game.snake)
    reset(&game)

    for !rl.WindowShouldClose() {
        if game.game_over {
            if rl.IsKeyPressed(.R) do reset(&game)
        } else {
            handle_input(&game)
            update(&game, rl.GetFrameTime())
        }
        rl.BeginDrawing()
        draw(game)
        rl.EndDrawing()
    }
}

The main loop has a single authority for phase ordering. During a live game, input updates next_dir, update advances the simulation, and the drawing bracket displays the resulting state. A terminal game accepts only reset input; it does not invoke update. defer delete(game.snake) releases the dynamic-array allocation after the window loop ends, before the program exits.

Mechanical Extensions

Nāgarāja extends the same state model with multiple item types, a charmer timer, shed-skin obstacles, procedural snake geometry, title and game-over screens, and sound effects. Each extension belongs to a specific phase:

  • An item type adds fields to Game and branches in the eating transition.
  • A timer accumulates dt in update, changes a state flag at a threshold, and can gate input without changing movement rules.
  • A shed skin is an additional occupied-cell collection checked beside the snake during collision.
  • A sound effect is emitted at the rule transition that caused it; asset loading and unloading remain outside the tick.
  • A more detailed snake renderer consumes the same ordered snake array and derives head, body, and tail appearance from index and neighbouring cells.

The underlying architecture remains stable because state ownership, candidate validation, committed mutation, and rendering are distinct operations. That separation is the part of Snake that transfers directly to grid puzzles, roguelikes, turn-based tactics, and other discrete game simulations.