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
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
}
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)
}
}
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))
}
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)
})
๐จ 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.
// ...
}
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)
}
}
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 }
}
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
})
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) }
}
}
}
๐งช 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)
})
})
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
- Type-Safe Code Generation: Claude generated strict TypeScript with proper generics, readonly modifiers, and exhaustive type guards
- Architectural Decisions: Suggested the fixed-timestep loop and pure function separation upfront
- Edge Cases: Caught issues like the tail exclusion in collision detection
- 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
}
}
noUncheckedIndexedAccess
was particularly valuable - it caught several potential runtime errors where array access could return undefined
.
๐ฏ Key Takeaways
Technical Lessons
- Type safety pays off: Strict TypeScript caught bugs before runtime
- Separation of concerns: Pure functions + reactive state = testable code
- Fixed timestep: Essential for consistent game behavior
- Canvas + Vue: Works great with proper lifecycle management
AI Development Insights
- Start with architecture: Claude excels at suggesting good patterns
- Iterate incrementally: Small, focused requests work best
- Review generated code: AI can miss edge cases (like the collision test)
- 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
๐ฎ 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)