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.
What is Conway's Game of Life? The Game of Life is a famous cellular automaton invented by mathematician John Conway. It consists of a grid of cells that "live" or "die" based on simple rules about their neighbors, creating fascinating patterns that evolve over time. In this project, I used the Game of Life to generate dynamic apple patterns for the Snake game.
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.
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).
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!