Part 2 continued: Claude Code Snake app

The snake-claude-code project is the richer local app. It keeps the page shell in App.jsx, moves the game into components/SnakeGame.jsx, and adds the behavior that makes the demo easier to play: start state, restart, score, WASD, and a mode switch.

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

The split is 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 do not 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')

Compared with the chat version, this app starts with no direction. The snake does not move until the player presses Space, which is a better fit for a demo because the game does not 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 is not reset here. The player chooses the mode before the game starts, and that mode remains selected across restarts.

Movement loop

The movement function exits early if the game has not 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 is 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 is a good change to ask an assistant for. The request is easy to state, the result is visible in the browser, and 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 current mode is shown below the button:

<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 (0)

Sign in to ask questions