The Snake game has one of the most recognisable origin stories in gaming: pre-installed on Nokia mobile phones from 1998, it introduced hundreds of millions of people to gaming on small screens. Despite decades passing since those early Nokia devices, Snake remains an excellent programming exercise that teaches data structures, game state management, and real-time input handling in a contained, testable context. Building Snake from scratch in JavaScript is a weekend project that produces something immediately satisfying and simultaneously teaches concepts applicable to professional software development.
The Snake as a Data Structure
The snake's body is represented as an ordered array of coordinate pairs (or objects with x and y properties), where the first element is the head and subsequent elements are body segments in order from head to tail. A snake with a body length of 5 occupying cells (5,3), (4,3), (3,3), (3,4), (3,5) would be represented as: [{x:5,y:3}, {x:4,y:3}, {x:3,y:3}, {x:3,y:4}, {x:3,y:5}].
This array structure is conceptually a deque (double-ended queue). On each game tick, the snake moves by adding a new head position to the front of the array and removing the tail position from the back — a constant-time O(1) operation that produces the visual effect of the snake moving forward. When the snake eats food, the tail removal is skipped — the tail stays in place for one tick, extending the snake's length by one segment.
Implementing this with JavaScript's native array: snake.unshift(newHead) adds the new head. snake.pop() removes the tail. When food is eaten, skip the pop() call. This two-line core update loop — unshift new head, conditionally pop tail — is the entire movement algorithm. The elegance of Snake as a data structure problem is how naturally the array deque maps to the visual behaviour.
Direction Control and Input Handling
The snake's current direction is stored as a vector: {dx: 1, dy: 0} for right, {dx: -1, dy: 0} for left, {dx: 0, dy: -1} for up, {dx: 0, dy: 1} for down (assuming y increases downward, which is typical for canvas coordinate systems). Each tick, the new head position is computed by adding the direction vector to the current head position.
A critical constraint: the snake cannot reverse direction — moving right and then immediately pressing left would cause immediate self-collision. The implementation must reject direction changes that are directly opposite to the current direction. Check: if the requested direction's x is the negative of the current direction's x (or y of y), reject the input. This is a single comparison that prevents the immediate-reversal edge case.
Direction input from the keyboard uses keydown event listeners on the arrow keys or WASD. A subtle issue: if the player presses two direction keys between game ticks (for example, quickly pressing both Up then Left before the next game update), the second input should be queued and applied in the following tick, not discarded. Implementing an input queue (a small array that accumulates pending direction changes, processed one per tick) prevents missed inputs during fast play and makes the controls feel responsive.
Collision Detection
Three collision conditions must be checked on each tick after computing the new head position. Wall collision: the new head position is outside the grid boundaries (x or y is negative, or greater than or equal to the grid width or height). Self-collision: the new head position matches any existing body segment coordinate. Food collision: the new head position matches the food position, triggering growth and food respawn.
Wall collision check: newHead.x < 0 || newHead.x >= GRID_WIDTH || newHead.y < 0 || newHead.y >= GRID_HEIGHT. Self-collision check: iterate the current body array and compare each segment's coordinates to the new head. This is O(n) in the snake's length, which is fine for typical snake lengths. For very long snakes, a Set of serialised coordinates (Set<"{x},{y}">) provides O(1) lookup at the cost of Set update overhead on each tick.
Wrapping walls (where the snake exits one side and appears on the opposite side) is an alternative to wall collision death, used in some Snake variants. Implement wrapping with modulo arithmetic: newHead.x = (newHead.x + GRID_WIDTH) % GRID_WIDTH. The addition of GRID_WIDTH before modulo handles negative values correctly (JavaScript's modulo operator can return negative values for negative operands).
Food Spawning Algorithm
Food must appear at a random empty grid cell — not at any cell occupied by the snake's body. A naive approach (generate random coordinates and retry if occupied) works well when the snake is short (most cells are empty), but degrades to poor average performance when the snake is very long (most cells are occupied, requiring many retries before finding an empty cell).
A more robust approach: build a list of all empty cells (all grid coordinates minus snake body coordinates) and select randomly from this list. This is O(grid size) per food spawn but is guaranteed to terminate in one attempt and ensures uniform distribution over empty cells. For a 20×20 grid with a maximum snake length of 399 cells, the empty cell list computation is a trivial cost.
Food coordinates should not overlap with the snake's current position at the moment of spawning. Checking against the entire snake array (not just the head) prevents food from spawning inside the body, which would be immediately unreachable. When the snake fills nearly the entire grid (a rare achievement requiring skill), the empty cell list may be very small or empty — check for this condition and handle it as a win state rather than an infinite loop.
Game Speed and Progression
Snake's game loop typically runs at a fixed tick rate (game updates per second) separate from the render frame rate. Common starting speeds: 10–15 ticks per second. As the snake grows, the tick rate increases, making the game progressively harder. A smooth progression: increase speed by 1 tick per second for every 5 food items eaten, up to a maximum speed of 25–30 ticks per second.
Implementing a separate tick timer alongside requestAnimationFrame: accumulate elapsed time per frame and process a game tick when the accumulated time exceeds the current tick interval. This is the same delta-time pattern used in all real-time games — it decouples game logic from rendering frame rate and ensures consistent game speed regardless of the browser's rendering performance.
Our Snake implementation uses a 20×20 grid (the classic Nokia-era scale), arrow key and WASD input, increasing speed with score, and wall collision death (no wrapping). High scores are persisted in localStorage so they survive page reloads. The game renders on an HTML5 Canvas with clean, minimal styling that works on both desktop and mobile browsers.