Part 2 continued: Claude Code Snake app

The snake-claude-code project gives you the richer local app. It keeps the page shell in App.jsx and moves the game into components/SnakeGame.jsx. On top of that it adds the start and restart flow, score, WASD controls, and a mode switch.

The finished app has two main files: snake-claude-code App.jsx and snake-claude-code SnakeGame.jsx.

The split marks the first visible difference from the chat app. A coding assistant that can edit a project can make structural changes, not only return one component.

App shell

The top-level app imports the game component and gives the page a centered layout:

import SnakeGame from './components/SnakeGame'

function App() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white">
      <h1 className="text-4xl font-bold mb-4">Snake Game</h1>
      <SnakeGame />
    </div>
  )
}

export default App

That file is small enough that future changes to game rules don't need to touch it. The game behavior sits in one place.

Game state

The game component starts with grid constants and state:

import { useState, useEffect, useCallback, useRef } from 'react'

const GRID_SIZE = 20
const CELL_SIZE = 20

function SnakeGame() {
  const [snake, setSnake] = useState([{ x: 10, y: 10 }])
  const [food, setFood] = useState({ x: 15, y: 15 })
  const [direction, setDirection] = useState({ x: 0, y: 0 })
  const [gameOver, setGameOver] = useState(false)
  const [score, setScore] = useState(0)
  const [gameStarted, setGameStarted] = useState(false)
  const [mode, setMode] = useState('walls')

Unlike the chat version, this app starts with no direction. The snake waits until the player presses Space. That better fits a demo because the game doesn't begin while the reader is still finding the browser window.

Food and reset

Food generation uses the current snake to avoid placing food on top of a segment:

const generateFood = useCallback(() => {
  let newFood
  do {
    newFood = {
      x: Math.floor(Math.random() * GRID_SIZE),
      y: Math.floor(Math.random() * GRID_SIZE)
    }
  } while (snake.some(segment => segment.x === newFood.x && segment.y === newFood.y))
  return newFood
}, [snake])

Reset returns all game state to the starting values:

const resetGame = () => {
  setSnake([{ x: 10, y: 10 }])
  setFood({ x: 15, y: 15 })
  setDirection({ x: 0, y: 0 })
  setGameOver(false)
  setScore(0)
  setGameStarted(false)
}

Notice that mode isn't reset here. The player chooses the mode before the game starts, and that mode stays selected across restarts.

Movement loop

The movement function exits early if the game hasn't started, has ended, or has no direction:

const moveSnake = useCallback(() => {
  if (!gameStarted || gameOver || (direction.x === 0 && direction.y === 0)) return

  setSnake(currentSnake => {
    const newSnake = [...currentSnake]
    const head = { ...newSnake[0] }

    head.x += direction.x
    head.y += direction.y

The pass-through mode wraps the snake around the board:

if (mode === 'pass-through') {
  if (head.x < 0) head.x = GRID_SIZE - 1
  if (head.x >= GRID_SIZE) head.x = 0
  if (head.y < 0) head.y = GRID_SIZE - 1
  if (head.y >= GRID_SIZE) head.y = 0
}

The walls mode ends the game when the head leaves the board:

if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) {
  setGameOver(true)
  return currentSnake
}

Self-collision ends the game in both modes:

if (newSnake.some(segment => segment.x === head.x && segment.y === head.y)) {
  setGameOver(true)
  return currentSnake
}

The score update happens in the food branch:

newSnake.unshift(head)

if (head.x === food.x && head.y === food.y) {
  setScore(prev => prev + 5)
  setFood(generateFood())
} else {
  newSnake.pop()
}

This is where score, food, and movement meet. It has state, branching, and visible behavior, but it's still small enough to review line by line.

Keyboard controls

The keyboard handler uses Space for start and restart before it handles movement:

const handleKeyPress = (e) => {
  if (!gameStarted) {
    if (e.code === 'Space') {
      setGameStarted(true)
      setDirection({ x: 1, y: 0 })
    }
    return
  }

  if (gameOver) {
    if (e.code === 'Space') {
      resetGame()
    }
    return
  }

Movement supports both arrow keys and WASD:

switch (e.code) {
  case 'ArrowUp':
  case 'KeyW':
    if (direction.y === 0) setDirection({ x: 0, y: -1 })
    break
  case 'ArrowDown':
  case 'KeyS':
    if (direction.y === 0) setDirection({ x: 0, y: 1 })
    break
  case 'ArrowLeft':
  case 'KeyA':
    if (direction.x === 0) setDirection({ x: -1, y: 0 })
    break
  case 'ArrowRight':
  case 'KeyD':
    if (direction.x === 0) setDirection({ x: 1, y: 0 })
    break
}

This change suits an assistant well:

  • The request is easy to state.
  • You can see the change in the browser.
  • The diff is small enough to review.

Interval management

The app stores the interval ID in a ref and recreates the interval when the movement callback changes:

const intervalRef = useRef(null)

useEffect(() => {
  if (intervalRef.current) clearInterval(intervalRef.current)
  intervalRef.current = setInterval(moveSnake, 150)
  return () => clearInterval(intervalRef.current)
}, [moveSnake])

This version ticks faster than the chat app: 150 milliseconds instead of 200. The speed difference is another visible comparison point when you play both versions.

Mode controls and status text

The mode button is disabled after the game starts:

<button
  className="px-3 py-1 bg-gray-700 text-white rounded mb-2 disabled:opacity-50"
  onClick={() => setMode(mode === 'walls' ? 'pass-through' : 'walls')}
  disabled={gameStarted}
>
  Switch to {mode === 'walls' ? 'Pass-Through' : 'Walls'} Mode
</button>

That avoids changing collision rules in the middle of a run.

The status text shows the current mode:

<div className="text-sm text-gray-300 mt-1">
  Mode: <span className="font-bold">{mode === 'walls' ? 'Walls' : 'Pass-Through'}</span>
</div>

The app renders different help text for waiting, playing, and game-over states:

{!gameStarted && !gameOver && (
  <p className="text-lg">Press SPACE to start</p>
)}
{gameOver && (
  <div>
    <p className="text-xl text-red-400 mb-2">Game Over!</p>
    <p className="text-lg">Press SPACE to restart</p>
  </div>
)}
{gameStarted && !gameOver && (
  <p className="text-sm text-gray-400">Use WASD or Arrow keys to move</p>
)}

Run the checks before leaving this part:

npm run lint
npm run build

The checks are part of the workflow, not decoration. The more power the assistant has to edit files, the more you need the project to give you fast feedback.

Questions & Answers

Sign up to ask questions, track your progress, and get access to other workshops · Already have an account? Sign in