shadimage
joyco
componentsnake-gamesource: ok · refreshed

Snake Game

A minimal, themeable snake game component with highscore persistence.

npx shadcn add "https://registry.joyco.studio/r/snake-game.json"
free

Visual style

minimal

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

@joyco/button@joyco/kbd@joyco/utils

Files7 · source cached 10/8/2026, 6:58:04 AM

registry/components/games/snake-game/index.ts
// Component
export { SnakeGame } from './snake-game'

// Hook (for advanced usage)
export { useSnakeGame } from './use-snake-game'

// Types
export type {
  // Core game types
  GameState,
  GameSnapshot,
  GameEndResult,
  Direction,

  // Geometry
  Position,

  // Game objects
  Snake,
  Food,
  HighscoreEntry,

  // Configuration
  SnakeGameConfig,
  SnakeGameProps,
  SnakeGameColors,
  SnakeGameGrid,
  SnakeGamePhysics,
  SnakeGameScoring,
  SnakeGameStorage,

  // Utilities
  DeepPartial,
  CanvasDimensions,
} from './types'

// Config presets
export {
  DEFAULT_CONFIG,
  DEFAULT_COLORS,
  GAME_CONSTANTS,
  KEY_BINDINGS,
  DIRECTION_VECTORS,
  OPPOSITE_DIRECTIONS,
} from './config'

// Utility functions (for custom implementations)
export {
  mergeConfig,
  createInitialSnake,
  generateFood,
  positionsEqual,
  isWithinBounds,
  checkSelfCollision,
  resolveCssColor,
  formatHighscoreDate,
  calculateSpeed,
  storage,
} from './utils'

registry/components/games/snake-game/snake-game.tsx
'use client'

import * as React from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import type {
  SnakeGameProps,
  SnakeGameConfig,
  CanvasDimensions,
  GameSnapshot,
  Direction,
} from './types'
import { DEFAULT_CONFIG, KEY_BINDINGS, GAME_CONSTANTS } from './config'
import { useSnakeGame } from './use-snake-game'
import { mergeConfig, resolveCssColor } from './utils'

/**
 * Render game to canvas
 */
function renderGame(
  ctx: CanvasRenderingContext2D,
  canvas: HTMLCanvasElement,
  snapshot: GameSnapshot,
  config: SnakeGameConfig,
  dimensions: CanvasDimensions
): void {
  const { size, cellSize, dpr } = dimensions

  // Clear canvas
  ctx.clearRect(0, 0, size * dpr, size * dpr)

  // Reset transform and scale for DPR
  ctx.setTransform(1, 0, 0, 1, 0, 0)
  ctx.scale(dpr, dpr)

  // Disable image smoothing for crisp pixels
  ctx.imageSmoothingEnabled = false

  // Resolve colors
  const snakeColor = resolveCssColor(config.colors.snake, canvas)
  const foodColor = resolveCssColor(config.colors.food, canvas)

  // Use integer cell size for crisp rendering
  const cell = Math.floor(cellSize)

  // Draw snake
  ctx.fillStyle = snakeColor
  snapshot.snake.segments.forEach((segment) => {
    ctx.fillRect(segment.x * cell, segment.y * cell, cell, cell)
  })

  // Draw food
  ctx.fillStyle = foodColor
  ctx.fillRect(
    snapshot.food.position.x * cell,
    snapshot.food.position.y * cell,
    cell,
    cell
  )
}

/**
 * Snake Game Component
 *
 * A minimal, themeable snake game that uses shadcn CSS variables
 * for automatic light/dark theme support.
 *
 * @example
 * ```tsx
 * <SnakeGame
 *   config={{ grid: { size: 15 } }}
 *   onGameEnd={({ score }) => console.log('Game over!', score)}
 *   className="w-full"
 * />
 * ```
 */
export function SnakeGame({
  config: configOverrides,
  onGameEnd,
  onScoreChange,
  onStateChange,
  className,
  showControls = true,
  showHighscores = true,
}: SnakeGameProps) {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  const [dimensions, setDimensions] = React.useState<CanvasDimensions>({
    size: 180,
    cellSize: 9,
    dpr: 1,
  })
  const [theme, setTheme] = React.useState('')

  // Merge config with defaults
  const config = React.useMemo<SnakeGameConfig>(
    () => mergeConfig(DEFAULT_CONFIG, configOverrides),
    [configOverrides]
  )

  // Use game hook
  const {
    gameState,
    snapshot,
    startGame,
    pauseGame,
    resumeGame,
    resetGame,
    setDirection,
  } = useSnakeGame({
    config,
    onGameEnd,
    onScoreChange,
    onStateChange,
  })

  // Handle responsive sizing
  React.useEffect(() => {
    const container = containerRef.current
    if (!container) return

    const updateSize = () => {
      const rect = container.getBoundingClientRect()
      // Round down to nearest multiple of grid size for clean pixels
      const size =
        Math.floor(rect.width / config.grid.size) * config.grid.size ||
        config.grid.size * 9
      const cellSize = size / config.grid.size
      const dpr = window.devicePixelRatio || 1

      setDimensions({ size, cellSize, dpr })
    }

    updateSize()

    const resizeObserver = new ResizeObserver(updateSize)
    resizeObserver.observe(container)

    return () => resizeObserver.disconnect()
  }, [config.grid.size])

  // Listen for theme changes
  React.useEffect(() => {
    const html = document.documentElement
    setTheme(html.className)

    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.attributeName === 'class') {
          setTheme(html.className)
        }
      }
    })

    observer.observe(html, { attributes: true, attributeFilter: ['class'] })
    return () => observer.disconnect()
  }, [])

  // Render game
  React.useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

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

    // Set canvas size for DPR
    canvas.width = dimensions.size * dimensions.dpr
    canvas.height = dimensions.size * dimensions.dpr

    renderGame(ctx, canvas, snapshot, config, dimensions)
  }, [snapshot, config, dimensions, theme])

  // Keyboard controls
  React.useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      const key = e.key
      const code = e.code

      if (gameState === 'playing') {
        let newDirection: Direction | null = null

        if (KEY_BINDINGS.UP.includes(key) || KEY_BINDINGS.UP.includes(code)) {
          newDirection = 'UP'
        } else if (
          KEY_BINDINGS.DOWN.includes(key) ||
          KEY_BINDINGS.DOWN.includes(code)
        ) {
          newDirection = 'DOWN'
        } else if (
          KEY_BINDINGS.LEFT.includes(key) ||
          KEY_BINDINGS.LEFT.includes(code)
        ) {
          newDirection = 'LEFT'
        } else if (
          KEY_BINDINGS.RIGHT.includes(key) ||
          KEY_BINDINGS.RIGHT.includes(code)
        ) {
          newDirection = 'RIGHT'
        }

        if (newDirection) {
          setDirection(newDirection)
          e.preventDefault()
        }

        if (
          KEY_BINDINGS.PAUSE.includes(key) ||
          KEY_BINDINGS.PAUSE.includes(code)
        ) {
          pauseGame()
          e.preventDefault()
        }
      } else if (gameState === 'paused') {
        if (
          KEY_BINDINGS.PAUSE.includes(key) ||
          KEY_BINDINGS.PAUSE.includes(code) ||
          KEY_BINDINGS.START.includes(key) ||
          KEY_BINDINGS.START.includes(code)
        ) {
          resumeGame()
          e.preventDefault()
        }
      } else if (gameState === 'idle' || gameState === 'gameOver') {
        if (
          KEY_BINDINGS.START.includes(key) ||
          KEY_BINDINGS.START.includes(code)
        ) {
          startGame()
          e.preventDefault()
        }
      }
    }

    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [gameState, setDirection, startGame, pauseGame, resumeGame])

  // Touch/swipe controls
  React.useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

    let touchStart: { x: number; y: number } | null = null

    const handleTouchStart = (e: TouchEvent) => {
      const touch = e.touches[0]
      touchStart = { x: touch.clientX, y: touch.clientY }
    }

    const handleTouchEnd = (e: TouchEvent) => {
      if (!touchStart || gameState !== 'playing') return

      const touch = e.changedTouches[0]
      const deltaX = touch.clientX - touchStart.x
      const deltaY = touch.clientY - touchStart.y

      if (Math.abs(deltaX) > Math.abs(deltaY)) {
        // Horizontal swipe
        if (deltaX > GAME_CONSTANTS.MIN_SWIPE_DISTANCE) {
          setDirection('RIGHT')
        } else if (deltaX < -GAME_CONSTANTS.MIN_SWIPE_DISTANCE) {
          setDirection('LEFT')
        }
      } else {
        // Vertical swipe
        if (deltaY > GAME_CONSTANTS.MIN_SWIPE_DISTANCE) {
          setDirection('DOWN')
        } else if (deltaY < -GAME_CONSTANTS.MIN_SWIPE_DISTANCE) {
          setDirection('UP')
        }
      }

      touchStart = null
    }

    canvas.addEventListener('touchstart', handleTouchStart, { passive: true })
    canvas.addEventListener('touchend', handleTouchEnd, { passive: true })

    return () => {
      canvas.removeEventListener('touchstart', handleTouchStart)
      canvas.removeEventListener('touchend', handleTouchEnd)
    }
  }, [gameState, setDirection])

  const handlePlayClick = () => {
    if (gameState === 'idle' || gameState === 'gameOver') {
      startGame()
    }
  }

  return (
    <div
      data-slot="snake-game"
      data-state={gameState}
      className={cn('bg-background flex w-full flex-col items-center', className)}
    >
      {/* Game Canvas */}
      <div
        ref={containerRef}
        data-slot="snake-game-canvas"
        className="bg-muted aspect-square w-full"
      >
        <canvas
          ref={canvasRef}
          style={{ width: dimensions.size, height: dimensions.size }}
          className="bg-muted size-full"
          role="img"
          aria-label={`Snake game - ${gameState === 'playing' ? `Score: ${snapshot.score}` : gameState}`}
        />
      </div>

      {/* Controls Bar */}
      {showControls && (
        <div
          data-slot="snake-game-controls"
          className="text-muted-foreground border-background flex w-full items-center justify-center border-t-4 font-mono text-xs"
        >
          {gameState === 'gameOver' ? (
            <div className="flex w-full items-center gap-1 pl-2">
              <span className="flex-1 uppercase">
                <span className="font-bold">F*ck!</span> Score:{' '}
                <span className="text-foreground font-bold">
                  {snapshot.score}
                </span>
              </span>
              <Button
                onClick={handlePlayClick}
                size="sm"
                className="h-8 rounded-none text-xs font-medium uppercase"
              >
                Play again
              </Button>
            </div>
          ) : gameState === 'playing' ? (
            <div className="flex w-full items-center gap-1">
              <div className="flex flex-1 px-3 whitespace-pre">
                Score:
                <span className="text-foreground font-bold">
                  {' '}
                  {snapshot.score}
                </span>
              </div>
              <span className="flex h-8 w-full shrink items-center pl-2">
                <div className="ml-auto flex gap-0.5">
                  {[
                    { desktop: 'W', mobile: '↑' },
                    { desktop: 'A', mobile: '←' },
                    { desktop: 'S', mobile: '↓' },
                    { desktop: 'D', mobile: '→' },
                  ].map(({ desktop, mobile }) => (
                    <Kbd key={desktop} className="rounded-none text-[10px]">
                      <span className="max-md:hidden">{desktop}</span>
                      <span className="md:hidden">{mobile}</span>
                    </Kbd>
                  ))}
                </div>
              </span>
            </div>
          ) : gameState === 'paused' ? (
            <Button
              onClick={resumeGame}
              size="sm"
              className="h-8 w-full rounded-none text-xs font-medium uppercase"
            >
              Resume
            </Button>
          ) : (
            <Button
              onClick={handlePlayClick}
              size="sm"
              className="h-8 w-full rounded-none text-xs font-medium uppercase"
            >
              Play Snake
            </Button>
          )}
        </div>
      )}

      {/* High Scores */}
      {showHighscores && snapshot.highscores.length > 0 && (
        <div
          data-slot="snake-game-highscores"
          className="border-background bg-accent/10 flex w-full flex-col border-t-4 px-3 py-2"
        >
          <span className="text-muted-foreground/60 mb-1 text-[10px] uppercase">
            Highscores
          </span>
          {snapshot.highscores.map((entry, i) => (
            <div
              key={`${entry.score}-${entry.date}-${i}`}
              className="text-muted-foreground flex items-center justify-between text-xs"
            >
              <span>
                <span className="text-foreground font-bold">{entry.score}</span>{' '}
                pts
              </span>
              <span className="text-muted-foreground/60">{entry.date}</span>
            </div>
          ))}
        </div>
      )}

      {/* Screen reader announcements */}
      <div className="sr-only" aria-live="polite" aria-atomic="true">
        {gameState === 'gameOver' &&
          `Game over. Final score: ${snapshot.score}`}
      </div>
    </div>
  )
}

registry/components/games/snake-game/types.ts
/** Game state machine states */
export type GameState = 'idle' | 'playing' | 'paused' | 'gameOver'

/** Direction the snake can move */
export type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'

/** 2D position on the grid */
export interface Position {
  x: number
  y: number
}

/** High score entry */
export interface HighscoreEntry {
  score: number
  date: string
}

/** Snake state */
export interface Snake {
  segments: Position[]
  direction: Direction
  nextDirection: Direction
}

/** Food state */
export interface Food {
  position: Position
}

/** Complete game state snapshot */
export interface GameSnapshot {
  state: GameState
  snake: Snake
  food: Food
  score: number
  highscores: HighscoreEntry[]
}

/** Color configuration using CSS custom properties */
export interface SnakeGameColors {
  /** Snake body color - default: 'var(--foreground)' */
  snake: string
  /** Food color - default: 'var(--primary)' */
  food: string
  /** Canvas background - default: 'var(--muted)' */
  background: string
}

/** Grid configuration */
export interface SnakeGameGrid {
  /** Grid size (cells per side) - default: 20 */
  size: number
}

/** Physics/speed configuration */
export interface SnakeGamePhysics {
  /** Initial game speed in ms per move - default: 150 */
  initialSpeed: number
  /** Speed decrease per food eaten (faster) - default: 2 */
  speedIncrement: number
  /** Minimum speed cap in ms - default: 50 */
  minSpeed: number
}

/** Scoring configuration */
export interface SnakeGameScoring {
  /** Points per food eaten - default: 1 */
  pointsPerFood: number
}

/** Storage configuration */
export interface SnakeGameStorage {
  /** localStorage key for high scores */
  storageKey: string
  /** Maximum high scores to keep */
  maxHighscores: number
}

/** Complete configuration object */
export interface SnakeGameConfig {
  colors: SnakeGameColors
  grid: SnakeGameGrid
  physics: SnakeGamePhysics
  scoring: SnakeGameScoring
  storage: SnakeGameStorage
}

/** Props for the SnakeGame component */
export interface SnakeGameProps {
  /** Partial config overrides (deep merged with defaults) */
  config?: DeepPartial<SnakeGameConfig>
  /** Called when game ends */
  onGameEnd?: (result: GameEndResult) => void
  /** Called when score updates */
  onScoreChange?: (score: number) => void
  /** Called when game state changes */
  onStateChange?: (state: GameState) => void
  /** Additional container className */
  className?: string
  /** Show built-in UI controls - default: true */
  showControls?: boolean
  /** Show high scores list - default: true */
  showHighscores?: boolean
}

/** Game end result passed to onGameEnd callback */
export interface GameEndResult {
  score: number
  highscores: HighscoreEntry[]
  snakeLength: number
}

/** Canvas dimensions */
export interface CanvasDimensions {
  size: number
  cellSize: number
  dpr: number
}

/** Deep partial utility type */
export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

registry/components/games/snake-game/config.ts
import type { SnakeGameConfig, SnakeGameColors } from './types'

/** Theme-aware default colors using CSS variables */
export const DEFAULT_COLORS: SnakeGameColors = {
  snake: 'var(--foreground)',
  food: 'var(--primary)',
  background: 'var(--muted)',
}

/** Complete default configuration */
export const DEFAULT_CONFIG: SnakeGameConfig = {
  colors: DEFAULT_COLORS,
  grid: {
    size: 20,
  },
  physics: {
    initialSpeed: 150,
    speedIncrement: 2,
    minSpeed: 50,
  },
  scoring: {
    pointsPerFood: 1,
  },
  storage: {
    storageKey: 'snake-highscores',
    maxHighscores: 4,
  },
}

/** Game constants (not configurable) */
export const GAME_CONSTANTS = {
  /** Initial snake length */
  INITIAL_SNAKE_LENGTH: 3,
  /** Minimum swipe distance for touch controls */
  MIN_SWIPE_DISTANCE: 30,
  /** System font stack for canvas text */
  FONT_FAMILY: 'system-ui, -apple-system, sans-serif',
} as const

/** Keyboard bindings */
export const KEY_BINDINGS = {
  UP: ['ArrowUp', 'KeyW', 'w', 'W'] as readonly string[],
  DOWN: ['ArrowDown', 'KeyS', 's', 'S'] as readonly string[],
  LEFT: ['ArrowLeft', 'KeyA', 'a', 'A'] as readonly string[],
  RIGHT: ['ArrowRight', 'KeyD', 'd', 'D'] as readonly string[],
  PAUSE: ['Escape', 'KeyP', 'p', 'P'] as readonly string[],
  START: ['Space', ' '] as readonly string[],
} as const

/** Direction vectors for movement */
export const DIRECTION_VECTORS = {
  UP: { x: 0, y: -1 },
  DOWN: { x: 0, y: 1 },
  LEFT: { x: -1, y: 0 },
  RIGHT: { x: 1, y: 0 },
} as const

/** Opposite directions (for preventing 180° turns) */
export const OPPOSITE_DIRECTIONS = {
  UP: 'DOWN',
  DOWN: 'UP',
  LEFT: 'RIGHT',
  RIGHT: 'LEFT',
} as const

registry/components/games/snake-game/utils.ts
import type {
  Position,
  SnakeGameConfig,
  DeepPartial,
  HighscoreEntry,
} from './types'
import { GAME_CONSTANTS } from './config'

/**
 * Deep merge configuration objects
 * @param base - Base configuration
 * @param overrides - Partial overrides to merge
 * @returns Merged configuration
 */
export function mergeConfig(
  base: SnakeGameConfig,
  overrides?: DeepPartial<SnakeGameConfig>
): SnakeGameConfig {
  if (!overrides) return base

  return {
    colors: { ...base.colors, ...overrides.colors },
    grid: { ...base.grid, ...overrides.grid },
    physics: { ...base.physics, ...overrides.physics },
    scoring: { ...base.scoring, ...overrides.scoring },
    storage: { ...base.storage, ...overrides.storage },
  }
}

/**
 * Create initial snake at center of grid
 * @param gridSize - Size of the grid
 * @returns Array of positions for initial snake
 */
export function createInitialSnake(gridSize: number): Position[] {
  const y = Math.floor(gridSize / 2)
  const headX = Math.floor(gridSize / 2)

  return Array.from({ length: GAME_CONSTANTS.INITIAL_SNAKE_LENGTH }, (_, i) => ({
    x: headX - i,
    y,
  }))
}

/**
 * Generate food position that doesn't overlap with snake
 * @param snake - Current snake segments
 * @param gridSize - Size of the grid
 * @returns New food position
 */
export function generateFood(snake: Position[], gridSize: number): Position {
  let newFood: Position
  do {
    newFood = {
      x: Math.floor(Math.random() * gridSize),
      y: Math.floor(Math.random() * gridSize),
    }
  } while (snake.some((s) => s.x === newFood.x && s.y === newFood.y))
  return newFood
}

/**
 * Check if two positions are equal
 * @param a - First position
 * @param b - Second position
 * @returns True if positions match
 */
export function positionsEqual(a: Position, b: Position): boolean {
  return a.x === b.x && a.y === b.y
}

/**
 * Check if position is within grid bounds
 * @param pos - Position to check
 * @param gridSize - Size of the grid
 * @returns True if within bounds
 */
export function isWithinBounds(pos: Position, gridSize: number): boolean {
  return pos.x >= 0 && pos.x < gridSize && pos.y >= 0 && pos.y < gridSize
}

/**
 * Check if snake collides with itself
 * @param snake - Snake segments (head is first element)
 * @returns True if head collides with body
 */
export function checkSelfCollision(snake: Position[]): boolean {
  const head = snake[0]
  return snake.slice(1).some((segment) => positionsEqual(head, segment))
}

/**
 * Resolve CSS variable to actual color value
 * @param color - Color string (may contain var())
 * @param element - DOM element to get computed styles from
 * @returns Resolved color value
 */
export function resolveCssColor(color: string, element: HTMLElement): string {
  if (!color.includes('var(')) return color

  const computed = getComputedStyle(element)
  const varMatch = color.match(/var\((--[^)]+)\)/)

  if (varMatch) {
    const varName = varMatch[1]
    const resolved = computed.getPropertyValue(varName).trim()
    return resolved || color
  }

  return color
}

/**
 * Format date for high score display
 * @param date - Date to format
 * @returns Formatted date string
 */
export function formatHighscoreDate(date: Date = new Date()): string {
  return date.toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
  })
}

/** Storage utilities for localStorage operations */
export const storage = {
  /**
   * Get high scores from localStorage
   * @param key - Storage key
   * @returns Array of high score entries
   */
  getHighscores(key: string): HighscoreEntry[] {
    if (typeof window === 'undefined') return []
    try {
      const stored = localStorage.getItem(key)
      return stored ? JSON.parse(stored) : []
    } catch {
      return []
    }
  },

  /**
   * Save high scores to localStorage
   * @param key - Storage key
   * @param scores - High score entries to save
   */
  setHighscores(key: string, scores: HighscoreEntry[]): void {
    if (typeof window === 'undefined') return
    try {
      localStorage.setItem(key, JSON.stringify(scores))
    } catch {
      // Storage full or disabled - silently fail
    }
  },

  /**
   * Add a new high score entry
   * @param key - Storage key
   * @param score - Score to add
   * @param maxEntries - Maximum entries to keep
   * @returns Updated high scores array
   */
  addHighscore(
    key: string,
    score: number,
    maxEntries: number
  ): HighscoreEntry[] {
    const current = storage.getHighscores(key)
    const newEntry: HighscoreEntry = {
      score,
      date: formatHighscoreDate(),
    }

    const updated = [...current, newEntry]
      .sort((a, b) => b.score - a.score)
      .slice(0, maxEntries)

    storage.setHighscores(key, updated)
    return updated
  },
}

/**
 * Calculate game speed based on score
 * @param initialSpeed - Starting speed in ms
 * @param speedIncrement - Speed decrease per point
 * @param minSpeed - Minimum speed cap
 * @param score - Current score
 * @returns Speed in ms
 */
export function calculateSpeed(
  initialSpeed: number,
  speedIncrement: number,
  minSpeed: number,
  score: number
): number {
  return Math.max(minSpeed, initialSpeed - score * speedIncrement)
}

registry/components/games/snake-game/use-snake-game.ts
'use client'

import * as React from 'react'
import type {
  GameState,
  GameSnapshot,
  GameEndResult,
  SnakeGameConfig,
  Direction,
  Position,
  HighscoreEntry,
} from './types'
import {
  createInitialSnake,
  generateFood,
  storage,
} from './utils'

interface UseSnakeGameOptions {
  config: SnakeGameConfig
  onGameEnd?: (result: GameEndResult) => void
  onScoreChange?: (score: number) => void
  onStateChange?: (state: GameState) => void
}

interface UseSnakeGameReturn {
  gameState: GameState
  snapshot: GameSnapshot
  startGame: () => void
  pauseGame: () => void
  resumeGame: () => void
  resetGame: () => void
  setDirection: (direction: Direction) => void
}

/**
 * Custom hook for snake game logic.
 * Mirrors the proven working implementation from the sidebar snake game.
 */
export function useSnakeGame(options: UseSnakeGameOptions): UseSnakeGameReturn {
  const { config, onGameEnd, onScoreChange, onStateChange } = options
  const gridSize = config.grid.size

  // Use separate isPlaying/gameOver booleans like original
  const [isPlaying, setIsPlaying] = React.useState(false)
  const [gameOver, setGameOver] = React.useState(false)
  const [snake, setSnake] = React.useState<Position[]>(() =>
    createInitialSnake(gridSize)
  )
  const [food, setFood] = React.useState<Position>(() =>
    generateFood(createInitialSnake(gridSize), gridSize)
  )
  const [direction, setDirectionState] = React.useState<Direction>('RIGHT')
  const [score, setScore] = React.useState(0)
  const [highscores, setHighscores] = React.useState<HighscoreEntry[]>([])

  const directionRef = React.useRef<Direction>(direction)
  const lastMovedDirectionRef = React.useRef<Direction>(direction)

  // Compute game state from booleans
  const gameState: GameState = gameOver
    ? 'gameOver'
    : isPlaying
      ? 'playing'
      : 'idle'

  // Load high scores on mount
  React.useEffect(() => {
    const loaded = storage.getHighscores(config.storage.storageKey)
    setHighscores(loaded)
  }, [config.storage.storageKey])

  // Sync direction ref
  React.useEffect(() => {
    directionRef.current = direction
  }, [direction])

  // Notify state changes
  React.useEffect(() => {
    onStateChange?.(gameState)
  }, [gameState, onStateChange])

  // Notify score changes
  React.useEffect(() => {
    onScoreChange?.(score)
  }, [score, onScoreChange])

  // Handle game over and save highscore
  React.useEffect(() => {
    if (gameOver && score > 0) {
      const updated = storage.addHighscore(
        config.storage.storageKey,
        score,
        config.storage.maxHighscores
      )
      setHighscores(updated)

      onGameEnd?.({
        score,
        highscores: updated,
        snakeLength: snake.length,
      })
    }
  }, [
    gameOver,
    score,
    snake.length,
    config.storage.storageKey,
    config.storage.maxHighscores,
    onGameEnd,
  ])

  // Reset game state - exactly like original
  const resetGame = React.useCallback(() => {
    const initialSnake = createInitialSnake(gridSize)
    setSnake(initialSnake)
    setFood(generateFood(initialSnake, gridSize))
    setDirectionState('RIGHT')
    directionRef.current = 'RIGHT'
    lastMovedDirectionRef.current = 'RIGHT'
    setGameOver(false)
    setScore(0)
    setIsPlaying(true)
  }, [gridSize])

  const startGame = resetGame

  const pauseGame = React.useCallback(() => {
    if (isPlaying && !gameOver) {
      setIsPlaying(false)
    }
  }, [isPlaying, gameOver])

  const resumeGame = React.useCallback(() => {
    if (!isPlaying && !gameOver) {
      setIsPlaying(true)
    }
  }, [isPlaying, gameOver])

  // Set direction (with 180° turn prevention)
  const setDirection = React.useCallback((newDirection: Direction) => {
    const lastMoved = lastMovedDirectionRef.current

    // Prevent 180° turns
    const opposites: Record<Direction, Direction> = {
      UP: 'DOWN',
      DOWN: 'UP',
      LEFT: 'RIGHT',
      RIGHT: 'LEFT',
    }

    if (opposites[newDirection] !== lastMoved) {
      setDirectionState(newDirection)
    }
  }, [])

  // Game loop - exactly like original
  React.useEffect(() => {
    if (!isPlaying || gameOver) return

    const moveSnake = () => {
      setSnake((prev) => {
        const head = { ...prev[0] }
        const dir = directionRef.current

        // Update last moved direction BEFORE moving
        lastMovedDirectionRef.current = dir

        // Move head
        if (dir === 'UP') head.y -= 1
        if (dir === 'DOWN') head.y += 1
        if (dir === 'LEFT') head.x -= 1
        if (dir === 'RIGHT') head.x += 1

        // Check wall collision
        if (
          head.x < 0 ||
          head.x >= gridSize ||
          head.y < 0 ||
          head.y >= gridSize
        ) {
          setGameOver(true)
          setIsPlaying(false)
          return prev
        }

        // Check self collision
        if (prev.some((s) => s.x === head.x && s.y === head.y)) {
          setGameOver(true)
          setIsPlaying(false)
          return prev
        }

        const newSnake = [head, ...prev]

        // Check food collision
        if (head.x === food.x && head.y === food.y) {
          setScore((s) => s + config.scoring.pointsPerFood)
          setFood(generateFood(newSnake, gridSize))
        } else {
          newSnake.pop()
        }

        return newSnake
      })
    }

    const interval = setInterval(moveSnake, config.physics.initialSpeed)
    return () => clearInterval(interval)
  }, [isPlaying, gameOver, food, gridSize, config.physics.initialSpeed, config.scoring.pointsPerFood])

  // Create snapshot
  const snapshot: GameSnapshot = {
    state: gameState,
    snake: {
      segments: snake,
      direction,
      nextDirection: direction,
    },
    food: {
      position: food,
    },
    score,
    highscores,
  }

  return {
    gameState,
    snapshot,
    startGame,
    pauseGame,
    resumeGame,
    resetGame,
    setDirection,
  }
}
registry/hooks/use-local-storage.ts
'use client'

import * as React from 'react'

/**
 * React hook for syncing state with localStorage.
 * Provides automatic serialization, SSR safety, and cross-tab sync.
 *
 * @example
 * ```tsx
 * const [value, setValue] = useLocalStorage('my-key', 'default')
 * ```
 */
export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
  // Get initial value from localStorage or use provided initial value
  const getStoredValue = React.useCallback((): T => {
    if (typeof window === 'undefined') {
      return initialValue
    }

    try {
      const item = window.localStorage.getItem(key)
      return item ? (JSON.parse(item) as T) : initialValue
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error)
      return initialValue
    }
  }, [key, initialValue])

  const [storedValue, setStoredValue] = React.useState<T>(getStoredValue)

  // Update localStorage when state changes
  const setValue: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
    (value) => {
      try {
        const valueToStore =
          value instanceof Function ? value(storedValue) : value

        setStoredValue(valueToStore)

        if (typeof window !== 'undefined') {
          window.localStorage.setItem(key, JSON.stringify(valueToStore))
        }
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error)
      }
    },
    [key, storedValue]
  )

  // Sync with localStorage changes from other tabs
  React.useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key && event.newValue !== null) {
        try {
          setStoredValue(JSON.parse(event.newValue) as T)
        } catch (error) {
          console.warn(`Error parsing localStorage change for "${key}":`, error)
        }
      }
    }

    window.addEventListener('storage', handleStorageChange)
    return () => window.removeEventListener('storage', handleStorageChange)
  }, [key])

  return [storedValue, setValue]
}

/**
 * Simple localStorage helpers for non-reactive access
 */
export const storage = {
  get<T>(key: string, defaultValue: T): T {
    if (typeof window === 'undefined') return defaultValue
    try {
      const item = localStorage.getItem(key)
      return item ? (JSON.parse(item) as T) : defaultValue
    } catch {
      return defaultValue
    }
  },

  set<T>(key: string, value: T): void {
    if (typeof window === 'undefined') return
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch {
      // Silently fail (e.g., quota exceeded)
    }
  },

  remove(key: string): void {
    if (typeof window === 'undefined') return
    try {
      localStorage.removeItem(key)
    } catch {
      // Silently fail
    }
  },
}

More from joyco

Browse all components in this library →