DEV Community

Cover image for Building a TypeScript Snake.io Game with Vue 3 and Claude Sonnet 4.5
A0mineTV
A0mineTV

Posted on

Building a TypeScript Snake.io Game with Vue 3 and Claude Sonnet 4.5

Introduction

I recently built a fully-featured snake.io-style game using Vue 3, TypeScript, and Canvas 2D - all with the help of Claude Sonnet 4.5. The result is a production-ready, type-safe game with multiple modes, touch controls, and comprehensive tests.

In this article, I'll walk you through the architecture, key technical decisions, and how AI-assisted development helped create clean, maintainable code.

๐ŸŽฎ Demo Features

The game includes:

  • 3 Game Modes: Classic (walls kill), Wrap-around, and Sprint (hold Space for speed boost)
  • Mobile Support: Full swipe controls with dead-zone detection
  • Power-ups: Magnet power-up that pulls food closer
  • Persistent High Score: Using localStorage
  • Responsive Design: Canvas scales perfectly to any screen size
  • TypeScript Strict Mode: Full type safety with noUncheckedIndexedAccess
  • Unit Tests: Comprehensive Vitest test suite

๐Ÿ—๏ธ Architecture Overview

Project Structure

src/
โ”œโ”€โ”€ types.ts                    # Type definitions & constants
โ”œโ”€โ”€ composables/
โ”‚   โ””โ”€โ”€ useSnakeGame.ts        # Game logic & state management
โ”œโ”€โ”€ components/
โ”‚   โ””โ”€โ”€ GameBoard.vue          # Canvas rendering & input handling
โ”œโ”€โ”€ App.vue                     # HUD & game shell
โ”œโ”€โ”€ main.ts                     # Application entry
โ””โ”€โ”€ __tests__/
    โ””โ”€โ”€ useSnakeGame.spec.ts   # Unit tests
Enter fullscreen mode Exit fullscreen mode

Type-First Design

One of the key decisions was to start with a types-first approach. All game entities are strongly typed:

// src/types.ts
export interface Vec2 {
  readonly x: number
  readonly y: number
}

export type Mode = 'classic' | 'wrap' | 'sprint'

export interface GameState {
  readonly boardSize: number
  snake: Vec2[]           // head at index 0
  direction: Vec2
  nextDir: Vec2[]        // input buffering
  food: Vec2
  powerUp: PowerUp | null
  score: number
  highScore: number
  isPaused: boolean
  isGameOver: boolean
  mode: Mode
  tickMs: number
  sprintActive: boolean
}
Enter fullscreen mode Exit fullscreen mode

This approach provides:

  • Compile-time safety: Catch errors before runtime
  • Better IDE support: Autocomplete and inline documentation
  • Self-documenting code: Types serve as living documentation

๐ŸŽฏ Game Logic: Fixed-Timestep Loop

The core game logic uses a fixed-timestep accumulator pattern - a technique borrowed from game engines to ensure consistent physics regardless of frame rate.

// src/composables/useSnakeGame.ts
function gameLoop(timestamp: number): void {
  if (!lastTickTime) {
    lastTickTime = timestamp
  }

  const deltaTime = timestamp - lastTickTime
  lastTickTime = timestamp
  accumulator += deltaTime

  const tickTime = effectiveTickMs.value

  // Process accumulated time in fixed steps
  while (accumulator >= tickTime) {
    const newState = step(state)
    Object.assign(state, newState)

    // Update power-up timer
    if (state.powerUp) {
      state.powerUp.ttlMs -= tickTime
      if (state.powerUp.ttlMs <= 0) {
        state.powerUp = null
      }
    }

    accumulator -= tickTime
  }

  if (!state.isGameOver) {
    animationFrameId = requestAnimationFrame(gameLoop)
  }
}
Enter fullscreen mode Exit fullscreen mode

Why fixed-timestep?

  • Deterministic: Game behaves the same at 30fps or 144fps
  • Predictable: Makes testing much easier
  • Professional: Industry-standard approach

๐Ÿงฉ Pure Functions for Testability

The game logic separates pure functions from reactive state management:

// Pure helper: can be tested in isolation
export function willCollide(
  nextHead: Vec2,
  snake: readonly Vec2[],
  mode: Mode,
  boardSize: number
): boolean {
  // Wall collision (only in classic mode)
  if (mode === 'classic') {
    if (nextHead.x < 0 || nextHead.x >= boardSize ||
        nextHead.y < 0 || nextHead.y >= boardSize) {
      return true
    }
  }

  // Self collision (check against body, excluding tail)
  const body = snake.slice(0, -1)
  return body.some((seg) => vecEquals(seg, nextHead))
}
Enter fullscreen mode Exit fullscreen mode

This design makes unit testing straightforward:

// src/__tests__/useSnakeGame.spec.ts
it('should detect wall collision in classic mode', () => {
  const snake: Vec2[] = [{ x: 10, y: 10 }, { x: 10, y: 11 }]
  const nextHead = { x: -1, y: 10 }

  expect(willCollide(nextHead, snake, 'classic', 20)).toBe(true)
})

it('should not detect wall collision in wrap mode', () => {
  const nextHead = { x: -1, y: 10 }
  expect(willCollide(nextHead, snake, 'wrap', 20)).toBe(false)
})
Enter fullscreen mode Exit fullscreen mode

๐ŸŽจ Canvas Rendering with Vue

The GameBoard.vue component handles all rendering and input:

function render(): void {
  const canvas = canvasRef.value
  if (!canvas) return

  const ctx = canvas.getContext('2d')
  if (!ctx) return

  const { boardSize, snake, food, powerUp } = props.gameState
  const size = cellSize.value

  // Clear canvas
  ctx.fillStyle = '#1a1a1a'
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  // Draw grid lines
  ctx.strokeStyle = '#2a2a2a'
  ctx.lineWidth = 1
  for (let i = 0; i <= boardSize; i++) {
    ctx.beginPath()
    ctx.moveTo(i * size, 0)
    ctx.lineTo(i * size, boardSize * size)
    ctx.stroke()
  }

  // Draw food, snake, etc.
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Key rendering features:

  • ResizeObserver: Canvas scales responsively while staying square
  • Separation of concerns: Rendering only reads reactive state
  • Performance: Only re-renders when game state actually changes

๐Ÿ“ฑ Mobile Touch Controls

Touch support required careful handling:

function handleTouchEnd(e: TouchEvent): void {
  e.preventDefault()
  const touch = e.changedTouches[0]
  if (!touch) return

  const deltaX = touch.clientX - touchStartX
  const deltaY = touch.clientY - touchStartY

  // Dead-zone for tap detection
  if (Math.abs(deltaX) < SWIPE_THRESHOLD &&
      Math.abs(deltaY) < SWIPE_THRESHOLD) {
    props.onPauseToggle()
    return
  }

  // Determine swipe direction
  if (Math.abs(deltaX) > Math.abs(deltaY)) {
    // Horizontal swipe
    props.onDirectionChange(deltaX > 0 ? DIR.RIGHT : DIR.LEFT)
  } else {
    // Vertical swipe
    props.onDirectionChange(deltaY > 0 ? DIR.DOWN : DIR.UP)
  }
}
Enter fullscreen mode Exit fullscreen mode

The SWIPE_THRESHOLD (30px) prevents accidental direction changes and allows tap-to-pause functionality.

๐ŸŽฎ Game Modes Implementation

Wrap-around Mode

function wrapPosition(pos: Vec2, boardSize: number): Vec2 {
  let { x, y } = pos
  if (x < 0) x = boardSize - 1
  if (x >= boardSize) x = 0
  if (y < 0) y = boardSize - 1
  if (y >= boardSize) y = 0
  return { x, y }
}
Enter fullscreen mode Exit fullscreen mode

Sprint Mode

Uses a computed property to modify tick speed:

const effectiveTickMs: ComputedRef<number> = computed(() => {
  if (state.mode === 'sprint' && state.sprintActive) {
    return Math.floor(state.tickMs / SPRINT_MULTIPLIER)
  }
  return state.tickMs
})
Enter fullscreen mode Exit fullscreen mode

Magnet Power-up

if (newPowerUp && newPowerUp.kind === 'magnet') {
  const dist = manhattanDistance(nextHead, newFood)
  if (dist <= MAGNET_RADIUS && dist > 0) {
    // Move food one step closer to snake head
    const dx = newFood.x - nextHead.x
    const dy = newFood.y - nextHead.y
    if (Math.abs(dx) > Math.abs(dy)) {
      newFood = { x: newFood.x - Math.sign(dx), y: newFood.y }
    } else {
      newFood = { x: newFood.x, y: newFood.y - Math.sign(dy) }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿงช Testing Strategy

The test suite covers core game logic:

describe('step', () => {
  it('should grow snake when eating food', () => {
    state.food = { x: 10, y: 9 } // Food directly ahead

    const newState = step(state)

    expect(newState.snake.length).toBe(4) // Grew by 1
    expect(newState.score).toBe(1)
    expect(newState.food).not.toEqual({ x: 10, y: 9 }) // Respawned
  })

  it('should wrap around edges in wrap mode', () => {
    state.mode = 'wrap'
    state.snake = [{ x: 0, y: 10 }, { x: 1, y: 10 }]
    state.direction = DIR.LEFT

    const newState = step(state)

    expect(newState.snake[0]?.x).toBe(DEFAULT_BOARD_SIZE - 1)
  })
})
Enter fullscreen mode Exit fullscreen mode

Test coverage includes:

  • Food spawning logic (never overlaps snake)
  • Collision detection (walls, self, modes)
  • State transitions (eating, growing, game over)
  • Edge wrapping behavior
  • Direction buffering and 180ยฐ prevention

๐Ÿค– AI-Assisted Development with Claude Sonnet 4.5

Working with Claude Sonnet 4.5 was incredibly productive. Here's what stood out:

What Worked Well

  1. Type-Safe Code Generation: Claude generated strict TypeScript with proper generics, readonly modifiers, and exhaustive type guards
  2. Architectural Decisions: Suggested the fixed-timestep loop and pure function separation upfront
  3. Edge Cases: Caught issues like the tail exclusion in collision detection
  4. Test Coverage: Generated comprehensive tests that actually found bugs

Example Interaction

When I asked for mobile touch controls, Claude:

  • Implemented swipe detection with proper threshold
  • Added tap-to-pause functionality
  • Prevented default scroll behavior
  • Used proper TypeScript event types

๐Ÿ“Š TypeScript Configuration

The project uses strict TypeScript settings:

{
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitAny": true
  }
}
Enter fullscreen mode Exit fullscreen mode

noUncheckedIndexedAccess was particularly valuable - it caught several potential runtime errors where array access could return undefined.

๐ŸŽฏ Key Takeaways

Technical Lessons

  1. Type safety pays off: Strict TypeScript caught bugs before runtime
  2. Separation of concerns: Pure functions + reactive state = testable code
  3. Fixed timestep: Essential for consistent game behavior
  4. Canvas + Vue: Works great with proper lifecycle management

AI Development Insights

  1. Start with architecture: Claude excels at suggesting good patterns
  2. Iterate incrementally: Small, focused requests work best
  3. Review generated code: AI can miss edge cases (like the collision test)
  4. Type-first approach: AI generates better code with clear type definitions

๐Ÿš€ Running the Project

# Install dependencies
npm install

# Development server
npm run dev

# Run tests
npm run test

# Build for production
npm run build
Enter fullscreen mode Exit fullscreen mode

๐ŸŽฎ Try It Yourself

The complete source code demonstrates:

  • Production-ready TypeScript patterns
  • Vue 3 Composition API best practices
  • Canvas 2D game development
  • Comprehensive testing with Vitest
  • Mobile-first responsive design

Controls:

  • Desktop: Arrow keys/WASD, P (pause), R (restart), Space (sprint)
  • Mobile: Swipe to move, tap to pause

Conclusion

Building this game with Claude Sonnet 4.5 showcased how AI-assisted development can accelerate projects while maintaining code quality. The combination of:

  • Strong typing (TypeScript strict mode)
  • Pure functions (testability)
  • Modern frameworks (Vue 3 Composition API)
  • AI assistance (architectural guidance)

...resulted in a maintainable, well-tested game that runs smoothly on desktop and mobile.

The future of development isn't AI replacing developers - it's AI helping developers write better code, faster.

Top comments (0)