Joseph Damiba
ReactNext.jsGame DevTypeScript

Challenges of Making a Modern Snake Game in React & Next.js

Joseph Damiba

What is Snake? Snake is a classic arcade game where you control a line (the "snake") that grows longer each time it eats food, but you lose if you run into yourself or the walls. The challenge is to survive as long as possible while the snake gets longer and harder to maneuver.

Building a Snake game might seem simple at first glance, but as I discovered while developing a feature-rich, visually dynamic version in React and Next.js, there are plenty of subtle and not-so-subtle challenges along the way. In this post, I'll share some of the real-world issues I encountered, how I debugged them, and the lessons learned—using examples from my own code and development process.

0. New Tournament Mode: 8 Unique AI Snakes

The latest version of the game introduces a full tournament mode! Now, 8 unique AI snakes compete in a bracket. Each snake has its own name, color, playstyle, and preferred apple shape. The tournament is structured as a series of 1v1 matches, with each player taking a turn. The winner is the snake with the highest score in each match, and the bracket advances until a champion is crowned.

  • Bracket System: 8 players, 4 matches in the first round, then semifinals and finals.
  • Match Flow: Each match consists of two runs (one per player), with a clear "score to beat" for the second player.
  • UI Improvements: Tournament bracket, countdown overlays, and clear winner announcements.

Unique Playstyles and Preferred Shapes

Each AI snake is now defined by a unique playstyle (aggressive, cautious, random, greedy, etc.) and a preferred apple shape (circle, triangle, square, etc.). The playstyle determines how the snake navigates the board, while the preferred shape gives bonus points when eaten. This makes each match-up feel distinct and adds a layer of strategy and personality to the tournament.

  • Playstyles: Each AI uses a different pathfinding algorithm, from classic A* to wall-hugging and zigzag patterns.
  • Preferred Shapes: Eating your preferred apple shape can give a bonus, and the UI shows which shapes each snake prefers.

Score to Beat: Real Tournament Tension

During the second run of each match, the UI now displays the "score to beat"—the first player's score. This adds real tension and clarity for both players and spectators, making it easy to follow who's winning and what's at stake.

Refactoring for Clarity and Maintainability

As the project grew, I refactored the tournament logic to be as clear and junior-friendly as possible. The state is minimal and explicit, the game flow is linear, and the UI overlays are controlled by simple booleans. This makes the codebase easy to follow, extend, and debug.

1. Apple Spawning: When Apples Refuse to Appear 🍏

One of the first issues I ran into was apples not spawning as expected. My game used a Game of Life mechanic to generate apples, but sometimes the grid would stabilize with no apples, or the forbidden set for apple spawning was too restrictive. Here's a snippet of the logic:

const forbidden = new Set(
  newSnakes.flatMap((s) => s.body.map(([x, y]) => `${x},${y}`))
);
const updated = nextLifeGrid(nextGrid, forbidden);
if (getAliveCells(updated).length === 0) {
  return createRandomPatternGrid(gridSize, 7, 3, 2, 2, 1);
}

The solution? I had to carefully balance the forbidden set and add fallbacks to ensure apples would always respawn, even if the Game of Life grid died out.

2. Color Consistency: Why Is My Snake Two Colors?

Another subtle bug: sometimes a snake's body would appear in two different colors. This happened because body segments from different snakes could be merged, or color assignment wasn't consistent. Here's a real example of the type definition and initialization:

body: Array.from(
  { length: 5 },
  (_, j) => [Math.floor(gridSize / 2) - j, Math.floor(gridSize / 2)] as [number, number]
),

The fix was to ensure that each snake's bodyColor was assigned once and never mixed, and that body arrays were never merged between snakes.

3. Hydration Mismatches & SSR: The Perils of Randomness

Next.js apps render on both the server and client, which means any random values or browser-only APIs (like localStorage) can cause hydration mismatches. I saw warnings like:

Hydration failed because the server rendered HTML didn't match the client.

The solution was to use a hasMounted flag and only render the game UI after the component had mounted on the client:

const [hasMounted, setHasMounted] = useState(false);
useEffect(() => { setHasMounted(true); }, []);
if (!hasMounted) return null;

4. TypeScript: Tuple Types and Linter Woes

TypeScript is great for catching bugs, but sometimes its strictness can be a hurdle. For example, I got this error:

Type 'number[][]' is not assignable to type '[number, number][]'.
  Type 'number[]' is not assignable to type '[number, number]'.

The fix? Explicitly cast arrays to the correct tuple type:

let newBody = ([[x, y], ...snake.body]) as [number, number][];

5. Modularization: Keeping the Codebase Maintainable

As the game grew, I broke it into multiple files: gameOfLife.ts for the Game of Life logic, colorUtils.ts for color helpers, SnakeHandlers.ts for event logic, and a custom useSnakeGame hook for state. This made the code much easier to reason about and extend.

6. Persistent State: Tracking the Longest Snake

I wanted to track the longest snake ever achieved, even across reloads. The solution was to use localStorage and only access it after mount:

useEffect(() => {
  setHasMounted(true);
  try {
    const stored = localStorage.getItem("longestSnakeLength");
    if (stored) setLongestSnakeLength(Number(stored));
  } catch (e) {}
}, []);

Lessons Learned

  • Always be mindful of SSR vs. client-only code in Next.js.
  • TypeScript tuple types can save you from subtle bugs, but require careful casting.
  • Modularizing your codebase early pays off as features grow.
  • Hydration mismatches are often caused by randomness or browser APIs—render only after mount if needed.
  • Debugging is easier when you log and visualize state changes (e.g., snake colors, apple positions).
  • Tournament logic is much easier to maintain when state and flow are kept simple and explicit.
  • Giving each AI a unique playstyle and preferred shape makes the game more fun and replayable.
  • UI features like "score to beat" and clear overlays make the tournament experience more engaging.

Building a Snake game was a fun and surprisingly deep technical challenge. If you're tackling a similar project, I hope these real-world examples help you avoid some of the pitfalls I encountered!