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 · lightRegistry 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
}
},
}