joyco
componentbrick-breakersource: ok · hit
Brick Breaker
A fully-featured brick breaker game with multiple levels, brick types, and proper collision physics.
npx shadcn add "https://registry.joyco.studio/r/brick-breaker.json"free
Where it goes
feature-grid
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
@joyco/utils
Files8 · source cached 10/14/2026, 3:07:39 PM
registry/components/games/brick-breaker/index.ts
// Component
export { BrickBreaker } from './brick-breaker'
// Hook (for advanced usage)
export { useBrickBreaker } from './use-brick-breaker'
// UI Components (for custom layouts)
export {
useBrickBreakerUI,
BrickBreakerUIProvider,
BrickBreakerCanvas,
BrickBreakerScore,
BrickBreakerHighScore,
BrickBreakerLevel,
BrickBreakerLives,
BrickBreakerHUD,
BrickBreakerOverlay,
BrickBreakerTitle,
BrickBreakerMessage,
BrickBreakerHint,
BrickBreakerScoreDisplay,
BrickBreakerActionButton,
BrickBreakerDefaultUI,
} from './ui'
// Types
export type {
// Core game types
GameState,
GameSnapshot,
GameEndResult,
// Geometry
Vector2D,
Bounds,
CollisionSide,
CollisionResult,
// Game objects
Brick,
BrickType,
BrickDefinition,
Ball,
Paddle,
// Levels
Level,
// Configuration
BrickBreakerConfig,
BrickBreakerProps,
BrickBreakerColors,
BrickBreakerLayout,
BrickBreakerSizing,
BrickBreakerPhysics,
BrickBreakerScoring,
BrickBreakerGameplay,
BrickBreakerEffects,
BrickBreakerStorage,
// Utilities
DeepPartial,
CanvasDimensions,
} from './types'
// Config presets
export {
DEFAULT_CONFIG,
DEFAULT_COLORS,
GAME_CONSTANTS,
KEY_BINDINGS,
BRICK_VISUALS,
} from './config'
// Level utilities
export {
DEFAULT_LEVELS,
createLevelFromPattern,
generateRandomLevel,
getBrickHealth,
isLevelCompletable,
countDestructibleBricks,
} from './levels'
// Utility functions
export {
mergeConfig,
detectCollision,
resolveBallBrickCollision,
resolveBallPaddleCollision,
resolveCssColor,
formatScore,
normalize,
magnitude,
scale,
add,
subtract,
dot,
reflect,
clamp,
lerp,
storage,
} from './utils'
registry/components/games/brick-breaker/brick-breaker.tsx
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
import type {
BrickBreakerProps,
BrickBreakerConfig,
CanvasDimensions,
GameSnapshot,
Brick,
BrickType,
} from './types'
import {
DEFAULT_CONFIG,
GAME_CONSTANTS,
KEY_BINDINGS,
BRICK_VISUALS,
} from './config'
import { DEFAULT_LEVELS } from './levels'
import { useBrickBreaker } from './use-brick-breaker'
import { mergeConfig, resolveCssColor } from './utils'
import {
BrickBreakerUIProvider,
BrickBreakerCanvas,
BrickBreakerDefaultUI,
} from './ui'
/**
* Draw rounded rectangle
*/
function drawRoundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number
): void {
ctx.beginPath()
ctx.moveTo(x + radius, y)
ctx.lineTo(x + width - radius, y)
ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
ctx.lineTo(x + width, y + height - radius)
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
ctx.lineTo(x + radius, y + height)
ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
ctx.lineTo(x, y + radius)
ctx.quadraticCurveTo(x, y, x + radius, y)
ctx.closePath()
ctx.fill()
}
/**
* Draw brick with type-specific patterns
*/
function drawBrick(
ctx: CanvasRenderingContext2D,
brick: Brick,
baseColor: string,
borderRadius: number
): void {
const { x, y, width, height } = brick.bounds
const visual = BRICK_VISUALS[brick.type]
ctx.globalAlpha = visual.opacity
// Health-based opacity for damaged bricks
if (brick.maxHealth > 1 && brick.health < brick.maxHealth) {
const healthRatio = brick.health / brick.maxHealth
ctx.globalAlpha *= 0.5 + healthRatio * 0.5
}
ctx.fillStyle = baseColor
drawRoundedRect(ctx, x, y, width, height, borderRadius)
// Draw pattern overlay for special bricks
if (visual.pattern && brick.type !== 'normal') {
ctx.globalAlpha = 0.15
if (visual.pattern === 'diagonal') {
// Strong brick: diagonal lines
ctx.strokeStyle = baseColor
ctx.lineWidth = 1
const step = 6
for (let i = -height; i < width + height; i += step) {
ctx.beginPath()
ctx.moveTo(x + i, y)
ctx.lineTo(x + i + height, y + height)
ctx.stroke()
}
} else if (visual.pattern === 'cross') {
// Metal brick: cross pattern
ctx.strokeStyle = baseColor
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(x + width * 0.2, y + height * 0.5)
ctx.lineTo(x + width * 0.8, y + height * 0.5)
ctx.moveTo(x + width * 0.5, y + height * 0.2)
ctx.lineTo(x + width * 0.5, y + height * 0.8)
ctx.stroke()
}
}
ctx.globalAlpha = 1
}
/**
* Render game to canvas
*/
function renderGame(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
snapshot: GameSnapshot,
config: BrickBreakerConfig,
dimensions: CanvasDimensions
): void {
const { width, height, dpr } = dimensions
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, height)
// Resolve colors
const bgColor = resolveCssColor(config.colors.background, canvas)
const paddleColor = resolveCssColor(config.colors.paddle, canvas)
const ballColor = resolveCssColor(config.colors.ball, canvas)
const trailColor = resolveCssColor(config.colors.ballTrail, canvas)
const brickColors: Record<BrickType, string> = {
normal: resolveCssColor(config.colors.bricks.normal, canvas),
strong: resolveCssColor(config.colors.bricks.strong, canvas),
metal: resolveCssColor(config.colors.bricks.metal, canvas),
indestructible: resolveCssColor(
config.colors.bricks.indestructible,
canvas
),
}
// Background
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, width, height)
// Draw bricks
const now = Date.now()
for (const brick of snapshot.bricks) {
if (brick.destroyed) {
// Fade out animation
if (brick.destroyedAt) {
const elapsed = now - brick.destroyedAt
const progress = elapsed / config.effects.destroyAnimationDuration
if (progress < 1) {
ctx.globalAlpha = 1 - progress
drawBrick(
ctx,
brick,
brickColors[brick.type],
config.layout.brickBorderRadius
)
ctx.globalAlpha = 1
}
}
continue
}
drawBrick(
ctx,
brick,
brickColors[brick.type],
config.layout.brickBorderRadius
)
}
// Helper to draw ball based on style
const drawBall = (
x: number,
y: number,
radius: number,
color: string,
alpha = 1
) => {
ctx.globalAlpha = alpha
ctx.fillStyle = color
if (config.layout.ballStyle === 'custom' && config.layout.renderBall) {
config.layout.renderBall(ctx, x, y, radius, color)
} else if (config.layout.ballStyle === 'square') {
const size = radius * 2
ctx.fillRect(x - radius, y - radius, size, size)
} else {
ctx.beginPath()
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.fill()
}
ctx.globalAlpha = 1
}
// Draw ball trail
if (config.effects.showTrail && snapshot.ball.trail.length > 0) {
for (let i = 0; i < snapshot.ball.trail.length; i++) {
const pos = snapshot.ball.trail[i]
const progress = (i + 1) / snapshot.ball.trail.length
const opacity = progress * config.effects.trailOpacity
const trailRadius = snapshot.ball.radius * (0.3 + 0.7 * progress)
drawBall(pos.x, pos.y, trailRadius, trailColor, opacity)
}
}
// Draw ball
drawBall(
snapshot.ball.position.x,
snapshot.ball.position.y,
snapshot.ball.radius,
ballColor
)
// Draw paddle
ctx.fillStyle = paddleColor
const paddleRadius =
config.layout.paddleBorderRadius === 'auto'
? snapshot.paddle.bounds.height / 2
: config.layout.paddleBorderRadius
drawRoundedRect(
ctx,
snapshot.paddle.bounds.x,
snapshot.paddle.bounds.y,
snapshot.paddle.bounds.width,
snapshot.paddle.bounds.height,
paddleRadius
)
// Note: HUD and overlays are now rendered as React components via children
}
/**
* Brick Breaker Game Component
*/
/** Input mode - once locked, other input types are ignored for movement */
type InputMode = 'none' | 'keyboard' | 'pointer'
export function BrickBreaker({
config: configOverrides,
levels: customLevels,
startLevel = 1,
onGameEnd,
onScoreChange,
onStateChange,
onLevelChange,
className,
autoFocus = true,
showFocusRing = true,
children,
}: BrickBreakerProps & { children?: React.ReactNode }) {
const containerRef = React.useRef<HTMLDivElement>(null)
const canvasWrapperRef = React.useRef<HTMLDivElement>(null)
const canvasRef = React.useRef<HTMLCanvasElement>(null)
const [dimensions, setDimensions] = React.useState<CanvasDimensions>({
width: 400,
height: 300,
dpr: 1,
})
const [theme, setTheme] = React.useState('')
// Input mode locking - prevents keyboard/mouse conflicts
const inputModeRef = React.useRef<InputMode>('none')
const config = React.useMemo<BrickBreakerConfig>(
() => mergeConfig(DEFAULT_CONFIG, configOverrides),
[configOverrides]
)
const levels = customLevels || DEFAULT_LEVELS
const {
snapshot,
startGame,
pauseGame,
resumeGame,
resetGame,
nextLevel,
movePaddle,
setPaddlePosition,
launchBall,
} = useBrickBreaker({
config,
levels,
startLevel,
canvasDimensions: dimensions,
onGameEnd,
onScoreChange,
onStateChange,
onLevelChange,
})
// Reset input mode when game is not playing
React.useEffect(() => {
if (snapshot.state !== 'playing') {
inputModeRef.current = 'none'
}
}, [snapshot.state])
// Responsive sizing - follows container width, maintains aspect ratio
React.useEffect(() => {
const wrapper = canvasWrapperRef.current
if (!wrapper) return
const updateSize = () => {
const rect = wrapper.getBoundingClientRect()
const wrapperWidth = Math.max(rect.width, 1)
const wrapperHeight = Math.max(rect.height, 1)
const dpr = window.devicePixelRatio || 1
// Calculate dimensions that fit within wrapper while maintaining aspect ratio
const heightFromWidth = wrapperWidth / GAME_CONSTANTS.ASPECT_RATIO
const widthFromHeight = wrapperHeight * GAME_CONSTANTS.ASPECT_RATIO
let width: number
let height: number
if (heightFromWidth <= wrapperHeight) {
// Width is the constraint - use full width
width = wrapperWidth
height = heightFromWidth
} else {
// Height is the constraint - use full height
width = widthFromHeight
height = wrapperHeight
}
setDimensions({ width, height, dpr })
}
updateSize()
const resizeObserver = new ResizeObserver(updateSize)
resizeObserver.observe(wrapper)
return () => resizeObserver.disconnect()
}, [])
// 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
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = dimensions.width * dimensions.dpr
canvas.height = dimensions.height * dimensions.dpr
renderGame(ctx, canvas, snapshot, config, dimensions)
}, [snapshot, config, dimensions, theme])
// Keyboard controls
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// only if is focusing on the canvas
if (
!document.activeElement?.closest('[data-slot="brick-breaker-canvas"]')
)
return
const code = e.code
// Movement keys - switch to keyboard mode (allows taking over from pointer)
if (
KEY_BINDINGS.LEFT.includes(code) ||
KEY_BINDINGS.RIGHT.includes(code)
) {
// Switch to keyboard mode - keyboard can always take over
inputModeRef.current = 'keyboard'
e.preventDefault()
if (KEY_BINDINGS.LEFT.includes(code)) {
movePaddle('left')
} else {
movePaddle('right')
}
return
}
// Action keys (always allowed regardless of input mode)
if (KEY_BINDINGS.START.includes(code)) {
e.preventDefault()
if (snapshot.state === 'idle') {
startGame()
} else if (snapshot.state === 'paused') {
resumeGame()
} else if (snapshot.state === 'playing') {
if (!snapshot.ball.isLaunched) {
launchBall()
} else {
pauseGame()
}
} else if (snapshot.state === 'levelComplete') {
nextLevel()
}
}
if (KEY_BINDINGS.PAUSE.includes(code)) {
e.preventDefault()
if (snapshot.state === 'playing') {
pauseGame()
} else if (snapshot.state === 'paused') {
resumeGame()
}
}
if (KEY_BINDINGS.RESTART.includes(code)) {
if (snapshot.state === 'won' || snapshot.state === 'lost') {
resetGame()
setTimeout(startGame, 100)
}
}
}
const handleKeyUp = (e: KeyboardEvent) => {
const code = e.code
if (
KEY_BINDINGS.LEFT.includes(code) ||
KEY_BINDINGS.RIGHT.includes(code)
) {
// Only respond if in keyboard mode
if (inputModeRef.current === 'keyboard') {
movePaddle('none')
// Reset so pointer can take over
inputModeRef.current = 'none'
}
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [
snapshot.state,
snapshot.ball.isLaunched,
startGame,
pauseGame,
resumeGame,
resetGame,
nextLevel,
movePaddle,
launchBall,
])
// Mouse controls
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const handleMouseMove = (e: MouseEvent) => {
if (snapshot.state !== 'playing') return
// Mouse can take over from null or pointer mode, but not keyboard
if (inputModeRef.current === 'keyboard') return
inputModeRef.current = 'pointer'
const rect = canvas.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * dimensions.width
setPaddlePosition(x)
}
const handleClick = () => {
// Click actions are always allowed (start, launch, resume, etc.)
if (snapshot.state === 'idle') {
startGame()
} else if (snapshot.state === 'paused') {
resumeGame()
} else if (snapshot.state === 'playing' && !snapshot.ball.isLaunched) {
launchBall()
} else if (snapshot.state === 'levelComplete') {
nextLevel()
} else if (snapshot.state === 'won' || snapshot.state === 'lost') {
resetGame()
setTimeout(startGame, 100)
}
}
const handleMouseLeave = () => {
// Reset pointer mode when mouse leaves canvas
if (inputModeRef.current === 'pointer') {
inputModeRef.current = 'none'
}
}
canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('click', handleClick)
canvas.addEventListener('mouseleave', handleMouseLeave)
return () => {
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('click', handleClick)
canvas.removeEventListener('mouseleave', handleMouseLeave)
}
}, [
snapshot.state,
snapshot.ball.isLaunched,
dimensions,
startGame,
resumeGame,
resetGame,
nextLevel,
setPaddlePosition,
launchBall,
])
// Touch controls
React.useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault()
// Touch always takes over input mode
inputModeRef.current = 'pointer'
// Position paddle immediately on touch
if (snapshot.state === 'playing' && e.touches[0]) {
const touch = e.touches[0]
const rect = canvas.getBoundingClientRect()
const x = ((touch.clientX - rect.left) / rect.width) * dimensions.width
setPaddlePosition(x)
}
// Touch actions (start, launch, etc.)
if (snapshot.state === 'idle') {
startGame()
} else if (snapshot.state === 'paused') {
resumeGame()
} else if (snapshot.state === 'playing' && !snapshot.ball.isLaunched) {
launchBall()
} else if (snapshot.state === 'levelComplete') {
nextLevel()
} else if (snapshot.state === 'won' || snapshot.state === 'lost') {
resetGame()
setTimeout(startGame, 100)
}
}
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault()
if (snapshot.state !== 'playing') return
if (!e.touches[0]) return
const touch = e.touches[0]
const rect = canvas.getBoundingClientRect()
const x = ((touch.clientX - rect.left) / rect.width) * dimensions.width
setPaddlePosition(x)
}
const handleTouchEnd = () => {
// Reset input mode when touch ends so keyboard can take over
if (inputModeRef.current === 'pointer') {
inputModeRef.current = 'none'
}
}
canvas.addEventListener('touchstart', handleTouchStart, { passive: false })
canvas.addEventListener('touchmove', handleTouchMove, { passive: false })
canvas.addEventListener('touchend', handleTouchEnd)
canvas.addEventListener('touchcancel', handleTouchEnd)
return () => {
canvas.removeEventListener('touchstart', handleTouchStart)
canvas.removeEventListener('touchmove', handleTouchMove)
canvas.removeEventListener('touchend', handleTouchEnd)
canvas.removeEventListener('touchcancel', handleTouchEnd)
}
}, [
snapshot.state,
snapshot.ball.isLaunched,
dimensions,
startGame,
resumeGame,
resetGame,
nextLevel,
setPaddlePosition,
launchBall,
])
// Auto-focus
React.useEffect(() => {
if (autoFocus) {
canvasRef.current?.focus()
}
}, [autoFocus])
// Context value for UI components
const uiContextValue = React.useMemo(
() => ({
snapshot,
startGame,
pauseGame,
resumeGame,
resetGame,
nextLevel,
}),
[snapshot, startGame, pauseGame, resumeGame, resetGame, nextLevel]
)
// Canvas element to render
const canvasElement = (
<canvas
ref={canvasRef}
data-slot="brick-breaker-canvas"
className={cn(
'block outline-none',
showFocusRing && 'focus-visible:ring-ring/50 focus-visible:ring-[3px]'
)}
style={{
width: dimensions.width,
height: dimensions.height,
imageRendering: 'crisp-edges',
}}
tabIndex={0}
role="img"
aria-label={`Brick Breaker - Level ${snapshot.level}, Score: ${snapshot.score}, Lives: ${snapshot.lives}`}
/>
)
// Resolve children, falling back to the default UI when none provided.
// The default UI is called as a function so we can inspect its children;
// React.Children.toArray does not flatten a top-level Fragment, so unwrap
// it explicitly before flattening so the canvas slot is detected.
const resolvedChildren = children ?? BrickBreakerDefaultUI()
const unwrapped =
React.isValidElement(resolvedChildren) &&
resolvedChildren.type === React.Fragment
? (resolvedChildren.props as { children?: React.ReactNode }).children
: resolvedChildren
const childArray = React.Children.toArray(unwrapped)
const isCanvasSlot = (child: React.ReactNode): child is React.ReactElement =>
React.isValidElement(child) && child.type === BrickBreakerCanvas
const hasCanvasSlot = childArray.some(isCanvasSlot)
// Renders the canvas + an inner relative frame sized to the canvas, so
// absolutely-positioned overlays (lives, state overlay) anchor to the
// canvas instead of the wider flex wrapper.
const renderCanvasFrame = (
extras: React.ReactNode,
wrapperClassName?: string
) => (
<div
key="canvas-wrapper"
ref={canvasWrapperRef}
className={cn(
'relative flex min-h-0 flex-1 items-center justify-center',
wrapperClassName
)}
>
<div
className="relative"
style={{ width: dimensions.width, height: dimensions.height }}
>
{canvasElement}
{extras}
</div>
</div>
)
// Replace the canvas slot with the actual canvas frame, rendering the
// slot's children inside it. Non-slot children (HUD, footers, etc) are
// wrapped in a canvas-width container and centered so they visually align
// with the canvas frame, even when the outer container is wider.
const processedChildren = hasCanvasSlot
? childArray.map((child, index) => {
if (isCanvasSlot(child)) {
const slotProps = child.props as {
className?: string
children?: React.ReactNode
}
return renderCanvasFrame(slotProps.children, slotProps.className)
}
return (
<div
key={`brick-breaker-hud-frame-${index}`}
className="self-center"
style={{ width: dimensions.width }}
>
{child}
</div>
)
})
: null
return (
<BrickBreakerUIProvider value={uiContextValue}>
<div
ref={containerRef}
data-slot="brick-breaker"
data-state={snapshot.state}
className={cn('relative flex flex-col', className)}
>
{hasCanvasSlot
? processedChildren
: // Legacy fallback: children provided without a canvas slot.
// Children render inside the canvas frame as overlays.
renderCanvasFrame(childArray)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{snapshot.state === 'won' &&
`You won! Final score: ${snapshot.score}`}
{snapshot.state === 'lost' && `Game over. Score: ${snapshot.score}`}
{snapshot.state === 'levelComplete' &&
`Level ${snapshot.level} complete! Score: ${snapshot.score}`}
</div>
</div>
</BrickBreakerUIProvider>
)
}
registry/components/games/brick-breaker/ui.tsx
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
import type { GameSnapshot, GameState } from './types'
import { formatScore } from './utils'
// ============================================================================
// Context
// ============================================================================
interface BrickBreakerUIContextValue {
snapshot: GameSnapshot
startGame: () => void
pauseGame: () => void
resumeGame: () => void
resetGame: () => void
nextLevel: () => void
}
const BrickBreakerUIContext =
React.createContext<BrickBreakerUIContextValue | null>(null)
export function useBrickBreakerUI() {
const context = React.useContext(BrickBreakerUIContext)
if (!context) {
throw new Error(
'BrickBreaker UI components must be used within BrickBreakerRoot'
)
}
return context
}
export const BrickBreakerUIProvider = BrickBreakerUIContext.Provider
// ============================================================================
// HUD Components
// ============================================================================
interface ScoreProps extends React.HTMLAttributes<HTMLDivElement> {
/** Show combo multiplier */
showCombo?: boolean
}
export function BrickBreakerScore({
showCombo = true,
className,
...props
}: ScoreProps) {
const { snapshot } = useBrickBreakerUI()
return (
<div
data-slot="brick-breaker-score"
className={cn('flex items-baseline gap-1.5', className)}
{...props}
>
<span className="text-foreground font-semibold tabular-nums">
{formatScore(snapshot.score)}
</span>
{showCombo && snapshot.combo > 0 && snapshot.state === 'playing' && (
<span className="text-muted-foreground text-xs tabular-nums">
×{snapshot.combo + 1}
</span>
)}
</div>
)
}
interface HighScoreProps extends React.HTMLAttributes<HTMLDivElement> {
/** Label prefix */
label?: string
}
export function BrickBreakerHighScore({
label = 'HI',
className,
...props
}: HighScoreProps) {
const { snapshot } = useBrickBreakerUI()
return (
<div
data-slot="brick-breaker-highscore"
className={cn('text-muted-foreground tabular-nums', className)}
{...props}
>
{label} {formatScore(snapshot.highScore)}
</div>
)
}
interface LevelProps extends React.HTMLAttributes<HTMLDivElement> {
/** Show total levels */
showTotal?: boolean
/** Label prefix */
label?: string
}
export function BrickBreakerLevel({
showTotal = true,
label = 'LVL',
className,
...props
}: LevelProps) {
const { snapshot } = useBrickBreakerUI()
return (
<div
data-slot="brick-breaker-level"
className={cn('text-muted-foreground tabular-nums', className)}
{...props}
>
{label} {snapshot.level}
{showTotal && `/${snapshot.totalLevels}`}
</div>
)
}
interface LivesProps extends React.HTMLAttributes<HTMLDivElement> {
/** Render function for each life indicator */
renderLife?: (index: number, isActive: boolean) => React.ReactNode
}
export function BrickBreakerLives({
renderLife,
className,
...props
}: LivesProps) {
const { snapshot } = useBrickBreakerUI()
const defaultRenderLife = (index: number) => (
<span key={index} className="bg-foreground/80 size-2 rounded-sm" />
)
return (
<div
data-slot="brick-breaker-lives"
className={cn('flex items-center gap-1', className)}
{...props}
>
{Array.from({ length: snapshot.lives }, (_, i) =>
renderLife ? renderLife(i, true) : defaultRenderLife(i)
)}
</div>
)
}
// ============================================================================
// Canvas Slot - marks where the canvas should render
// ============================================================================
export interface BrickBreakerCanvasProps extends React.HTMLAttributes<HTMLDivElement> {
/** Additional class names for the canvas wrapper */
className?: string
}
/**
* Slot component that marks where the game canvas should be rendered.
* Children are rendered inside the canvas frame (use `absolute` positioning
* for overlays, lives, etc).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function BrickBreakerCanvas(_props: BrickBreakerCanvasProps) {
// Marker component - actual canvas + children are rendered by BrickBreaker.
return null
}
BrickBreakerCanvas.displayName = 'BrickBreakerCanvas'
// ============================================================================
// HUD Container
// ============================================================================
interface HUDProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode
}
export function BrickBreakerHUD({ children, className, ...props }: HUDProps) {
return (
<div
data-slot="brick-breaker-hud"
className={cn(
'grid shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-4 p-4 text-sm',
'[&>:first-child]:justify-self-start',
'[&>:last-child]:justify-self-end',
className
)}
{...props}
>
{children}
</div>
)
}
// ============================================================================
// Overlay Components
// ============================================================================
interface OverlayProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode
}
export function BrickBreakerOverlay({
children,
className,
...props
}: OverlayProps) {
const { snapshot } = useBrickBreakerUI()
if (snapshot.state === 'playing') return null
return (
<div
data-slot="brick-breaker-overlay"
data-state={snapshot.state}
className={cn(
'bg-background/90 pointer-events-none absolute inset-0 flex flex-col items-center justify-center',
className
)}
{...props}
>
{children}
</div>
)
}
BrickBreakerOverlay.displayName = 'BrickBreakerOverlay'
interface TitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children?: React.ReactNode
}
export function BrickBreakerTitle({
children,
className,
...props
}: TitleProps) {
const { snapshot } = useBrickBreakerUI()
const defaultTitles: Record<GameState, string> = {
idle: 'BRICK BREAKER',
playing: '',
paused: 'PAUSED',
won: 'YOU WIN!',
lost: 'GAME OVER',
levelComplete: 'LEVEL COMPLETE!',
}
return (
<h2
data-slot="brick-breaker-title"
className={cn(
'text-foreground text-2xl font-bold tracking-tight',
className
)}
{...props}
>
{children ?? defaultTitles[snapshot.state]}
</h2>
)
}
interface MessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
children?: React.ReactNode
}
export function BrickBreakerMessage({
children,
className,
...props
}: MessageProps) {
const { snapshot } = useBrickBreakerUI()
const defaultMessages: Partial<Record<GameState, string>> = {
idle: 'Click or press Space to start',
paused: 'Press Space to resume',
won: snapshot.score >= snapshot.highScore ? 'NEW HIGH SCORE!' : undefined,
lost: `Score: ${formatScore(snapshot.score)}`,
levelComplete: `Score: ${formatScore(snapshot.score)}`,
}
const message = children ?? defaultMessages[snapshot.state]
if (!message) return null
return (
<p
data-slot="brick-breaker-message"
className={cn('text-muted-foreground mt-2', className)}
{...props}
>
{message}
</p>
)
}
interface HintProps extends React.HTMLAttributes<HTMLParagraphElement> {
children?: React.ReactNode
}
export function BrickBreakerHint({ children, className, ...props }: HintProps) {
const { snapshot } = useBrickBreakerUI()
const defaultHints: Partial<Record<GameState, string>> = {
won: 'Press R to play again',
lost: 'Press R to try again',
levelComplete: 'Click to continue',
}
const hint = children ?? defaultHints[snapshot.state]
if (!hint) return null
return (
<p
data-slot="brick-breaker-hint"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
>
{hint}
</p>
)
}
interface ScoreDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode
}
export function BrickBreakerScoreDisplay({
children,
className,
...props
}: ScoreDisplayProps) {
const { snapshot } = useBrickBreakerUI()
if (snapshot.state === 'idle' || snapshot.state === 'playing') return null
return (
<div
data-slot="brick-breaker-score-display"
className={cn('text-foreground mt-4 text-lg font-medium', className)}
{...props}
>
{children ?? `Score: ${formatScore(snapshot.score)}`}
</div>
)
}
// ============================================================================
// Action Button
// ============================================================================
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode
}
export function BrickBreakerActionButton({
children,
className,
onClick,
...props
}: ActionButtonProps) {
const { snapshot, startGame, resumeGame, resetGame, nextLevel } =
useBrickBreakerUI()
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e)
switch (snapshot.state) {
case 'idle':
startGame()
break
case 'paused':
resumeGame()
break
case 'won':
case 'lost':
resetGame()
setTimeout(startGame, 100)
break
case 'levelComplete':
nextLevel()
break
}
}
const defaultLabels: Partial<Record<GameState, string>> = {
idle: 'Start Game',
paused: 'Resume',
won: 'Play Again',
lost: 'Try Again',
levelComplete: 'Next Level',
}
if (snapshot.state === 'playing') return null
return (
<button
type="button"
data-slot="brick-breaker-action"
className={cn(
'bg-foreground text-background hover:bg-foreground/90 mt-6 rounded-md px-6 py-2 text-sm font-medium transition-colors',
className
)}
onClick={handleClick}
{...props}
>
{children ?? defaultLabels[snapshot.state]}
</button>
)
}
// ============================================================================
// Default UI Preset
// ============================================================================
export function BrickBreakerDefaultUI() {
return (
<>
<BrickBreakerHUD>
<BrickBreakerScore />
<BrickBreakerLevel />
<BrickBreakerHighScore />
</BrickBreakerHUD>
<BrickBreakerCanvas>
<BrickBreakerLives className="absolute bottom-3 left-3" />
<BrickBreakerOverlay>
<BrickBreakerTitle />
<BrickBreakerMessage />
<BrickBreakerHint />
</BrickBreakerOverlay>
</BrickBreakerCanvas>
</>
)
}
registry/components/games/brick-breaker/types.ts
/** Game state machine states */
export type GameState = 'idle' | 'playing' | 'paused' | 'won' | 'lost' | 'levelComplete'
/** Brick types with different behaviors */
export type BrickType =
| 'normal' // Standard brick, 1 hit
| 'strong' // 2 hits to destroy
| 'metal' // 3 hits to destroy
| 'indestructible' // Cannot be destroyed
/** 2D Vector for positions and velocities */
export interface Vector2D {
x: number
y: number
}
/** Rectangle bounds for collision detection */
export interface Bounds {
x: number
y: number
width: number
height: number
}
/** Collision side for proper bounce direction */
export type CollisionSide = 'top' | 'bottom' | 'left' | 'right' | 'corner'
/** Individual brick state */
export interface Brick {
id: string
row: number
col: number
bounds: Bounds
type: BrickType
health: number
maxHealth: number
destroyed: boolean
destroyedAt?: number
points: number
}
/** Brick definition in level data (simplified for level design) */
export interface BrickDefinition {
/** Brick type - determines health and behavior */
type: BrickType
/** Optional custom points override */
points?: number
}
/** Level definition */
export interface Level {
/** Level number (1-indexed for display) */
id: number
/** Level name */
name: string
/** Grid of brick definitions. Use null for empty cells. */
bricks: (BrickDefinition | null)[][]
/** Ball speed multiplier for this level */
speedMultiplier?: number
/** Background color override */
backgroundColor?: string
}
/** Paddle state */
export interface Paddle {
bounds: Bounds
targetX: number | null
speed: number
}
/** Ball state */
export interface Ball {
position: Vector2D
velocity: Vector2D
radius: number
speed: number
trail: Vector2D[]
isLaunched: boolean
}
/** Complete game state (mutable, stored in refs) */
export interface GameEngine {
state: GameState
level: number
score: number
lives: number
bricks: Brick[]
paddle: Paddle
ball: Ball
combo: number
lastHitTime: number
}
/** Immutable snapshot for React rendering */
export interface GameSnapshot {
state: GameState
level: number
levelName: string
score: number
highScore: number
lives: number
bricks: Brick[]
paddle: Paddle
ball: Ball
combo: number
totalLevels: number
}
/** Color configuration using CSS custom properties */
export interface BrickBreakerColors {
background: string
paddle: string
ball: string
ballTrail: string
text: string
textMuted: string
/** Colors by brick type */
bricks: {
normal: string
strong: string
metal: string
indestructible: string
}
}
/** Layout configuration */
export interface BrickBreakerLayout {
/** Number of brick columns */
cols: number
/** Number of brick rows */
rows: number
/** Gap between bricks in pixels */
brickGap: number
/** Top padding for score area (ratio of height) */
topPadding: number
/** Side padding (ratio of width) */
sidePadding: number
/** Brick border radius in pixels */
brickBorderRadius: number
/** Paddle border radius in pixels (0 for sharp corners) */
paddleBorderRadius: number | 'auto'
/** Ball style: 'round', 'square', or 'custom' (requires renderBall) */
ballStyle: 'round' | 'square' | 'custom'
/** Custom ball renderer (used when ballStyle is 'custom') */
renderBall?: (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
color: string
) => void
}
/** Size ratios relative to canvas dimensions */
export interface BrickBreakerSizing {
paddleWidth: number
paddleHeight: number
ballRadius: number
paddleOffset: number
}
/** Physics configuration */
export interface BrickBreakerPhysics {
/** Base ball speed (pixels per frame at 60fps) */
baseSpeed: number
/** Speed increase per level */
speedPerLevel: number
/** Maximum ball speed cap */
maxSpeed: number
/** Paddle movement speed */
paddleSpeed: number
/** Max bounce angle from paddle edges (radians) */
maxBounceAngle: number
/** Minimum Y velocity to prevent horizontal loops */
minYVelocity: number
}
/** Scoring configuration */
export interface BrickBreakerScoring {
/** Points by brick type */
pointsByType: {
normal: number
strong: number
metal: number
indestructible: number
}
/** Combo multiplier increment */
comboMultiplier: number
/** Combo timeout in ms */
comboTimeout: number
/** Max combo multiplier */
maxCombo: number
/** Points for completing a level */
levelBonus: number
}
/** Gameplay configuration */
export interface BrickBreakerGameplay {
/** Starting lives */
startingLives: number
/** Max lives */
maxLives: number
}
/** Visual effects configuration */
export interface BrickBreakerEffects {
showTrail: boolean
trailLength: number
trailOpacity: number
destroyAnimationDuration: number
/** Screen shake on brick hit */
screenShake: boolean
}
/** Storage configuration */
export interface BrickBreakerStorage {
persistHighScore: boolean
persistProgress: boolean
storageKey: string
}
/** Complete configuration object */
export interface BrickBreakerConfig {
colors: BrickBreakerColors
layout: BrickBreakerLayout
sizing: BrickBreakerSizing
physics: BrickBreakerPhysics
scoring: BrickBreakerScoring
gameplay: BrickBreakerGameplay
effects: BrickBreakerEffects
storage: BrickBreakerStorage
}
/** Props for the BrickBreaker component */
export interface BrickBreakerProps {
/** Partial config overrides */
config?: DeepPartial<BrickBreakerConfig>
/** Custom levels (overrides built-in levels) */
levels?: Level[]
/** Starting level (1-indexed) */
startLevel?: number
/** Called when game ends */
onGameEnd?: (result: GameEndResult) => void
/** Called when score updates */
onScoreChange?: (score: number, combo: number) => void
/** Called when game state changes */
onStateChange?: (state: GameState) => void
/** Called when level changes */
onLevelChange?: (level: number) => void
/** Additional container className */
className?: string
/** Auto-focus canvas on mount */
autoFocus?: boolean
/** Show focus ring when canvas is focused (default: true) */
showFocusRing?: boolean
}
/** Game end result */
export interface GameEndResult {
won: boolean
score: number
highScore: number
level: number
totalLevels: number
bricksDestroyed: number
totalBricks: number
}
/** Collision detection result */
export interface CollisionResult {
collided: boolean
side?: CollisionSide
normal?: Vector2D
penetration?: number
contactPoint?: Vector2D
}
/** Canvas dimensions */
export interface CanvasDimensions {
width: number
height: 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/brick-breaker/config.ts
import type { BrickBreakerConfig, BrickBreakerColors } from './types'
/** Theme-aware default colors using CSS variables */
export const DEFAULT_COLORS: BrickBreakerColors = {
background: 'var(--background)',
paddle: 'var(--foreground)',
ball: 'var(--foreground)',
ballTrail: 'var(--muted-foreground)',
text: 'var(--foreground)',
textMuted: 'var(--muted-foreground)',
bricks: {
normal: 'var(--foreground)',
strong: 'var(--foreground)',
metal: 'var(--foreground)',
indestructible: 'var(--muted-foreground)',
},
}
/** Complete default configuration */
export const DEFAULT_CONFIG: BrickBreakerConfig = {
colors: DEFAULT_COLORS,
layout: {
cols: 8,
rows: 5,
brickGap: 4,
topPadding: 0.14,
sidePadding: 0.04,
brickBorderRadius: 2,
paddleBorderRadius: 'auto', // 'auto' = height/2 for pill shape
ballStyle: 'round',
},
sizing: {
paddleWidth: 0.18,
paddleHeight: 0.025,
ballRadius: 0.012,
paddleOffset: 0.08,
},
physics: {
baseSpeed: 5,
speedPerLevel: 0.3,
maxSpeed: 12,
paddleSpeed: 10,
maxBounceAngle: Math.PI / 3, // 60 degrees
minYVelocity: 2,
},
scoring: {
pointsByType: {
normal: 10,
strong: 25,
metal: 50,
indestructible: 0,
},
comboMultiplier: 0.25,
comboTimeout: 2000,
maxCombo: 10,
levelBonus: 500,
},
gameplay: {
startingLives: 3,
maxLives: 5,
},
effects: {
showTrail: true,
trailLength: 6,
trailOpacity: 0.4,
destroyAnimationDuration: 100, // Quick fade out (100ms)
screenShake: false,
},
storage: {
persistHighScore: true,
persistProgress: false,
storageKey: 'brick-breaker',
},
}
/** Game constants */
export const GAME_CONSTANTS = {
TARGET_FPS: 60,
FRAME_TIME: 1000 / 60,
ASPECT_RATIO: 4 / 3,
FONT_FAMILY: 'system-ui, -apple-system, sans-serif',
/** Grace period after losing a life (ms) */
RESPAWN_DELAY: 1000,
/** Time before ball auto-launches (ms) */
AUTO_LAUNCH_DELAY: 3000,
} as const
/** Keyboard bindings */
export const KEY_BINDINGS = {
LEFT: ['ArrowLeft', 'KeyA'] as readonly string[],
RIGHT: ['ArrowRight', 'KeyD'] as readonly string[],
START: ['Space'] as readonly string[],
PAUSE: ['Escape', 'KeyP'] as readonly string[],
RESTART: ['KeyR'] as readonly string[],
} as const
/** Brick visual properties by type */
export const BRICK_VISUALS = {
normal: {
opacity: 1,
pattern: null,
},
strong: {
opacity: 0.85,
pattern: 'diagonal',
},
metal: {
opacity: 0.7,
pattern: 'cross',
},
indestructible: {
opacity: 0.4,
pattern: 'solid',
},
} as const
registry/components/games/brick-breaker/levels.ts
import type { Level, BrickDefinition } from './types'
/** Shorthand brick definitions for level design */
const N: BrickDefinition = { type: 'normal' }
const S: BrickDefinition = { type: 'strong' }
const M: BrickDefinition = { type: 'metal' }
const X: BrickDefinition = { type: 'indestructible' }
const _: null = null // Empty space
/**
* Built-in levels for the brick breaker game.
* Each level is a 2D array where:
* - N = normal brick (1 hit)
* - S = strong brick (2 hits)
* - M = metal brick (3 hits)
* - X = indestructible brick
* - _ = empty space
*/
export const DEFAULT_LEVELS: Level[] = [
// Level 1: Introduction - Simple rows
{
id: 1,
name: 'First Steps',
speedMultiplier: 1.0,
bricks: [
[N, N, N, N, N, N, N, N],
[N, N, N, N, N, N, N, N],
[N, N, N, N, N, N, N, N],
[_, _, _, _, _, _, _, _],
[_, _, _, _, _, _, _, _],
],
},
// Level 2: Checkerboard
{
id: 2,
name: 'Checkerboard',
speedMultiplier: 1.0,
bricks: [
[N, _, N, _, N, _, N, _],
[_, N, _, N, _, N, _, N],
[N, _, N, _, N, _, N, _],
[_, N, _, N, _, N, _, N],
[N, _, N, _, N, _, N, _],
],
},
// Level 3: Pyramid
{
id: 3,
name: 'Pyramid',
speedMultiplier: 1.05,
bricks: [
[_, _, _, N, N, _, _, _],
[_, _, N, N, N, N, _, _],
[_, N, N, N, N, N, N, _],
[N, N, N, N, N, N, N, N],
[_, _, _, _, _, _, _, _],
],
},
// Level 4: Introducing Strong Bricks
{
id: 4,
name: 'Getting Stronger',
speedMultiplier: 1.05,
bricks: [
[N, N, N, N, N, N, N, N],
[N, S, S, S, S, S, S, N],
[N, S, N, N, N, N, S, N],
[N, S, S, S, S, S, S, N],
[N, N, N, N, N, N, N, N],
],
},
// Level 5: Diamond
{
id: 5,
name: 'Diamond',
speedMultiplier: 1.1,
bricks: [
[_, _, _, S, S, _, _, _],
[_, _, S, N, N, S, _, _],
[_, S, N, N, N, N, S, _],
[_, _, S, N, N, S, _, _],
[_, _, _, S, S, _, _, _],
],
},
// Level 6: Walls
{
id: 6,
name: 'The Walls',
speedMultiplier: 1.1,
bricks: [
[S, _, N, N, N, N, _, S],
[S, _, N, N, N, N, _, S],
[S, _, N, N, N, N, _, S],
[S, _, N, N, N, N, _, S],
[S, _, N, N, N, N, _, S],
],
},
// Level 7: Introducing Metal
{
id: 7,
name: 'Metal Core',
speedMultiplier: 1.15,
bricks: [
[N, N, N, N, N, N, N, N],
[N, S, S, S, S, S, S, N],
[N, S, M, M, M, M, S, N],
[N, S, S, S, S, S, S, N],
[N, N, N, N, N, N, N, N],
],
},
// Level 8: Stripes
{
id: 8,
name: 'Stripes',
speedMultiplier: 1.15,
bricks: [
[M, S, N, S, M, S, N, S],
[S, N, S, M, S, N, S, M],
[N, S, M, S, N, S, M, S],
[S, M, S, N, S, M, S, N],
[M, S, N, S, M, S, N, S],
],
},
// Level 9: Fortress with Indestructible
{
id: 9,
name: 'Fortress',
speedMultiplier: 1.2,
bricks: [
[X, N, N, N, N, N, N, X],
[N, S, S, S, S, S, S, N],
[N, S, M, M, M, M, S, N],
[N, S, S, S, S, S, S, N],
[X, N, N, N, N, N, N, X],
],
},
// Level 10: Final Challenge
{
id: 10,
name: 'The Gauntlet',
speedMultiplier: 1.25,
bricks: [
[X, M, S, N, N, S, M, X],
[M, S, N, N, N, N, S, M],
[S, N, N, M, M, N, N, S],
[M, S, N, N, N, N, S, M],
[X, M, S, N, N, S, M, X],
],
},
]
/**
* Create a custom level from a pattern string.
* Use this for quick level prototyping.
*
* @example
* ```ts
* const level = createLevelFromPattern(1, 'My Level', `
* N N N N N N N N
* . S S S S S S .
* . S M M M M S .
* . S S S S S S .
* N N N N N N N N
* `)
* ```
*/
export function createLevelFromPattern(
id: number,
name: string,
pattern: string,
speedMultiplier: number = 1.0
): Level {
const lines = pattern
.trim()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
const bricks: (BrickDefinition | null)[][] = lines.map((line) => {
const cells = line.split(/\s+/)
return cells.map((cell) => {
switch (cell.toUpperCase()) {
case 'N':
return { type: 'normal' as const }
case 'S':
return { type: 'strong' as const }
case 'M':
return { type: 'metal' as const }
case 'X':
return { type: 'indestructible' as const }
case '.':
case '_':
case '0':
return null
default:
return null
}
})
})
return { id, name, bricks, speedMultiplier }
}
/**
* Generate a random level with given parameters.
*/
export function generateRandomLevel(
id: number,
cols: number = 8,
rows: number = 5,
difficulty: number = 0.5
): Level {
const bricks: (BrickDefinition | null)[][] = []
for (let row = 0; row < rows; row++) {
const rowBricks: (BrickDefinition | null)[] = []
for (let col = 0; col < cols; col++) {
// Higher rows have stronger bricks
const rowDifficulty = (rows - row) / rows
const rand = Math.random()
if (rand < 0.1) {
// 10% empty
rowBricks.push(null)
} else if (rand < 0.1 + 0.5 * (1 - difficulty * rowDifficulty)) {
// Normal bricks
rowBricks.push({ type: 'normal' })
} else if (rand < 0.8) {
// Strong bricks
rowBricks.push({ type: 'strong' })
} else if (rand < 0.95) {
// Metal bricks
rowBricks.push({ type: 'metal' })
} else {
// Rare indestructible
rowBricks.push({ type: 'indestructible' })
}
}
bricks.push(rowBricks)
}
return {
id,
name: `Random Level ${id}`,
bricks,
speedMultiplier: 1 + difficulty * 0.3,
}
}
/**
* Get brick health by type
*/
export function getBrickHealth(type: BrickDefinition['type']): number {
switch (type) {
case 'normal':
return 1
case 'strong':
return 2
case 'metal':
return 3
case 'indestructible':
return Infinity
}
}
/**
* Check if a level is completable (has at least one destructible brick)
*/
export function isLevelCompletable(level: Level): boolean {
return level.bricks.some((row) =>
row.some((brick) => brick !== null && brick.type !== 'indestructible')
)
}
/**
* Count total destructible bricks in a level
*/
export function countDestructibleBricks(level: Level): number {
let count = 0
for (const row of level.bricks) {
for (const brick of row) {
if (brick !== null && brick.type !== 'indestructible') {
count++
}
}
}
return count
}
registry/components/games/brick-breaker/utils.ts
import type {
Vector2D,
Bounds,
CollisionResult,
CollisionSide,
BrickBreakerConfig,
DeepPartial,
Ball,
Brick,
Paddle,
} from './types'
// ============================================================================
// Configuration Utilities
// ============================================================================
/**
* Deep merge configuration objects
*/
export function mergeConfig(
base: BrickBreakerConfig,
overrides?: DeepPartial<BrickBreakerConfig>
): BrickBreakerConfig {
if (!overrides) return base
return {
colors: {
...base.colors,
...overrides.colors,
bricks: { ...base.colors.bricks, ...overrides.colors?.bricks },
},
layout: { ...base.layout, ...overrides.layout },
sizing: { ...base.sizing, ...overrides.sizing },
physics: { ...base.physics, ...overrides.physics },
scoring: {
...base.scoring,
...overrides.scoring,
pointsByType: {
...base.scoring.pointsByType,
...overrides.scoring?.pointsByType,
},
},
gameplay: { ...base.gameplay, ...overrides.gameplay },
effects: { ...base.effects, ...overrides.effects },
storage: { ...base.storage, ...overrides.storage },
}
}
// ============================================================================
// Vector Math
// ============================================================================
/** Normalize vector to unit length */
export function normalize(v: Vector2D): Vector2D {
const len = Math.sqrt(v.x * v.x + v.y * v.y)
if (len === 0) return { x: 0, y: -1 }
return { x: v.x / len, y: v.y / len }
}
/** Calculate vector magnitude */
export function magnitude(v: Vector2D): number {
return Math.sqrt(v.x * v.x + v.y * v.y)
}
/** Scale vector by scalar */
export function scale(v: Vector2D, s: number): Vector2D {
return { x: v.x * s, y: v.y * s }
}
/** Add two vectors */
export function add(a: Vector2D, b: Vector2D): Vector2D {
return { x: a.x + b.x, y: a.y + b.y }
}
/** Subtract vectors (a - b) */
export function subtract(a: Vector2D, b: Vector2D): Vector2D {
return { x: a.x - b.x, y: a.y - b.y }
}
/** Dot product of two vectors */
export function dot(a: Vector2D, b: Vector2D): number {
return a.x * b.x + a.y * b.y
}
/** Reflect velocity off a surface normal */
export function reflect(velocity: Vector2D, normal: Vector2D): Vector2D {
const d = dot(velocity, normal)
return {
x: velocity.x - 2 * d * normal.x,
y: velocity.y - 2 * d * normal.y,
}
}
/** Clamp value between min and max */
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
/** Linear interpolation */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
// ============================================================================
// Collision Detection - AABB vs Circle with Proper Side Detection
// ============================================================================
/**
* Detect collision between a circle (ball) and axis-aligned rectangle (brick/paddle).
* Returns collision info including which side was hit for proper bounce direction.
*/
export function detectCollision(
ballPos: Vector2D,
ballRadius: number,
ballVel: Vector2D,
rect: Bounds
): CollisionResult {
// Find the closest point on the rectangle to the ball center
const closestX = clamp(ballPos.x, rect.x, rect.x + rect.width)
const closestY = clamp(ballPos.y, rect.y, rect.y + rect.height)
// Calculate distance from ball center to closest point
const dx = ballPos.x - closestX
const dy = ballPos.y - closestY
const distSq = dx * dx + dy * dy
const radiusSq = ballRadius * ballRadius
// No collision if distance > radius
if (distSq > radiusSq) {
return { collided: false }
}
const dist = Math.sqrt(distSq)
const penetration = ballRadius - dist
// Determine collision side based on approach direction and position
let side: CollisionSide
let normal: Vector2D
if (dist < 0.001) {
// Ball center is inside rect - use velocity to determine exit direction
const rectCenterX = rect.x + rect.width / 2
const rectCenterY = rect.y + rect.height / 2
const toBallX = ballPos.x - rectCenterX
const toBallY = ballPos.y - rectCenterY
// Determine which edge is closest
const halfW = rect.width / 2
const halfH = rect.height / 2
const overlapX = halfW - Math.abs(toBallX)
const overlapY = halfH - Math.abs(toBallY)
if (overlapX < overlapY) {
// Exit through left or right
if (toBallX > 0) {
side = 'right'
normal = { x: 1, y: 0 }
} else {
side = 'left'
normal = { x: -1, y: 0 }
}
} else {
// Exit through top or bottom
if (toBallY > 0) {
side = 'bottom'
normal = { x: 0, y: 1 }
} else {
side = 'top'
normal = { x: 0, y: -1 }
}
}
} else {
// Normal case - calculate normal from closest point
normal = { x: dx / dist, y: dy / dist }
// Determine side based on where closest point is
const isOnLeft = closestX <= rect.x + 0.01
const isOnRight = closestX >= rect.x + rect.width - 0.01
const isOnTop = closestY <= rect.y + 0.01
const isOnBottom = closestY >= rect.y + rect.height - 0.01
if (isOnTop && !isOnLeft && !isOnRight) {
side = 'top'
normal = { x: 0, y: -1 }
} else if (isOnBottom && !isOnLeft && !isOnRight) {
side = 'bottom'
normal = { x: 0, y: 1 }
} else if (isOnLeft && !isOnTop && !isOnBottom) {
side = 'left'
normal = { x: -1, y: 0 }
} else if (isOnRight && !isOnTop && !isOnBottom) {
side = 'right'
normal = { x: 1, y: 0 }
} else {
// Corner collision
side = 'corner'
// Keep the calculated normal for corners
}
}
return {
collided: true,
side,
normal,
penetration,
contactPoint: { x: closestX, y: closestY },
}
}
/**
* Resolve ball collision - move ball out of collision and reflect velocity.
* Returns the new ball state.
*/
export function resolveBallBrickCollision(
ball: Ball,
brick: Brick,
collision: CollisionResult,
config: BrickBreakerConfig
): Ball {
if (!collision.collided || !collision.normal || collision.penetration === undefined) {
return ball
}
// Move ball out of collision
const newPos = {
x: ball.position.x + collision.normal.x * (collision.penetration + 0.5),
y: ball.position.y + collision.normal.y * (collision.penetration + 0.5),
}
// Reflect velocity
let newVel = reflect(ball.velocity, collision.normal)
// Ensure minimum Y velocity to prevent horizontal loops
if (Math.abs(newVel.y) < config.physics.minYVelocity) {
newVel.y = newVel.y >= 0 ? config.physics.minYVelocity : -config.physics.minYVelocity
// Renormalize to maintain speed
const speed = ball.speed
newVel = scale(normalize(newVel), speed)
}
return {
...ball,
position: newPos,
velocity: newVel,
}
}
/**
* Handle ball-paddle collision with angle adjustment based on hit position.
*/
export function resolveBallPaddleCollision(
ball: Ball,
paddle: Paddle,
collision: CollisionResult,
config: BrickBreakerConfig
): Ball {
if (!collision.collided || !collision.normal || collision.penetration === undefined) {
return ball
}
// Only bounce if ball is moving downward
if (ball.velocity.y <= 0) {
return ball
}
// Calculate hit position relative to paddle center (-1 to 1)
const paddleCenterX = paddle.bounds.x + paddle.bounds.width / 2
const hitPos = (ball.position.x - paddleCenterX) / (paddle.bounds.width / 2)
const clampedHitPos = clamp(hitPos, -1, 1)
// Calculate bounce angle based on hit position
// Center = straight up, edges = angled
const bounceAngle = clampedHitPos * config.physics.maxBounceAngle
// Create new velocity from angle
const speed = ball.speed
const newVel: Vector2D = {
x: Math.sin(bounceAngle) * speed,
y: -Math.cos(bounceAngle) * speed,
}
// Move ball above paddle
const newY = paddle.bounds.y - ball.radius - 1
return {
...ball,
position: { x: ball.position.x, y: newY },
velocity: newVel,
}
}
// ============================================================================
// Utility Functions
// ============================================================================
/** Generate unique ID */
export function generateId(): string {
return Math.random().toString(36).substring(2, 9)
}
/** Resolve CSS variable to actual 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 score with thousands separator */
export function formatScore(score: number): string {
return score.toLocaleString()
}
/** Storage utilities */
export const storage = {
get(key: string, defaultValue: number = 0): number {
if (typeof window === 'undefined') return defaultValue
try {
const value = localStorage.getItem(key)
return value ? parseInt(value, 10) : defaultValue
} catch {
return defaultValue
}
},
set(key: string, value: number): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(key, value.toString())
} catch {
// Silently fail
}
},
getJSON<T>(key: string, defaultValue: T): T {
if (typeof window === 'undefined') return defaultValue
try {
const value = localStorage.getItem(key)
return value ? JSON.parse(value) : defaultValue
} catch {
return defaultValue
}
},
setJSON<T>(key: string, value: T): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// Silently fail
}
},
}
/** Degrees to radians */
export function degToRad(degrees: number): number {
return (degrees * Math.PI) / 180
}
/** Create velocity vector from angle and speed */
export function velocityFromAngle(angle: number, speed: number): Vector2D {
return {
x: Math.cos(angle) * speed,
y: Math.sin(angle) * speed,
}
}
/**
* Check if ball is moving toward a rectangle (for early collision skip)
*/
export function isBallMovingToward(ball: Ball, rect: Bounds): boolean {
const rectCenterX = rect.x + rect.width / 2
const rectCenterY = rect.y + rect.height / 2
const toRect = {
x: rectCenterX - ball.position.x,
y: rectCenterY - ball.position.y,
}
return dot(ball.velocity, toRect) > 0
}
registry/components/games/brick-breaker/use-brick-breaker.ts
'use client'
import * as React from 'react'
import type {
GameState,
GameSnapshot,
GameEndResult,
BrickBreakerConfig,
Brick,
Ball,
Paddle,
Level,
CanvasDimensions,
BrickType,
} from './types'
import { DEFAULT_CONFIG, GAME_CONSTANTS } from './config'
import { DEFAULT_LEVELS, getBrickHealth } from './levels'
import {
detectCollision,
resolveBallBrickCollision,
resolveBallPaddleCollision,
generateId,
clamp,
normalize,
scale,
magnitude,
storage,
} from './utils'
interface UseBrickBreakerOptions {
config: BrickBreakerConfig
levels: Level[]
startLevel: number
canvasDimensions: CanvasDimensions
onGameEnd?: (result: GameEndResult) => void
onScoreChange?: (score: number, combo: number) => void
onStateChange?: (state: GameState) => void
onLevelChange?: (level: number) => void
}
interface UseBrickBreakerReturn {
snapshot: GameSnapshot
startGame: () => void
pauseGame: () => void
resumeGame: () => void
resetGame: () => void
nextLevel: () => void
movePaddle: (direction: 'left' | 'right' | 'none') => void
setPaddlePosition: (x: number) => void
launchBall: () => void
}
/**
* Create bricks from level definition
*/
function createBricksFromLevel(
level: Level,
config: BrickBreakerConfig,
dimensions: CanvasDimensions
): Brick[] {
const { brickGap, topPadding, sidePadding } = config.layout
const { width, height } = dimensions
const rows = level.bricks.length
const cols = level.bricks[0]?.length || config.layout.cols
const playAreaX = width * sidePadding
const playAreaY = height * topPadding
const playAreaWidth = width * (1 - 2 * sidePadding)
const playAreaHeight = height * 0.35
const brickWidth = (playAreaWidth - (cols - 1) * brickGap) / cols
const brickHeight = (playAreaHeight - (rows - 1) * brickGap) / rows
const bricks: Brick[] = []
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const def = level.bricks[row]?.[col]
if (!def) continue
const health = getBrickHealth(def.type)
const points = def.points ?? config.scoring.pointsByType[def.type]
bricks.push({
id: generateId(),
row,
col,
bounds: {
x: playAreaX + col * (brickWidth + brickGap),
y: playAreaY + row * (brickHeight + brickGap),
width: brickWidth,
height: brickHeight,
},
type: def.type,
health,
maxHealth: health,
destroyed: false,
points,
})
}
}
return bricks
}
/**
* Create paddle
*/
function createPaddle(
config: BrickBreakerConfig,
dimensions: CanvasDimensions
): Paddle {
const { paddleWidth, paddleHeight, paddleOffset } = config.sizing
const { width, height } = dimensions
const w = width * paddleWidth
const h = height * paddleHeight
return {
bounds: {
x: (width - w) / 2,
y: height * (1 - paddleOffset) - h,
width: w,
height: h,
},
targetX: null,
speed: config.physics.paddleSpeed,
}
}
/**
* Create ball attached to paddle
*/
function createBall(
config: BrickBreakerConfig,
dimensions: CanvasDimensions,
paddle: Paddle,
levelIndex: number
): Ball {
const { ballRadius } = config.sizing
const { width } = dimensions
const { baseSpeed, speedPerLevel, maxSpeed } = config.physics
const radius = width * ballRadius
const speed = Math.min(baseSpeed + levelIndex * speedPerLevel, maxSpeed)
return {
position: {
x: paddle.bounds.x + paddle.bounds.width / 2,
y: paddle.bounds.y - radius - 2,
},
velocity: { x: 0, y: 0 },
radius,
speed,
trail: [],
isLaunched: false,
}
}
/**
* Launch ball from paddle with random angle
*/
function launchBallFromPaddle(ball: Ball): Ball {
// Random angle between -45 and -135 degrees (upward arc)
const angle = -Math.PI / 2 + (Math.random() - 0.5) * (Math.PI / 2)
return {
...ball,
velocity: {
x: Math.cos(angle) * ball.speed,
y: Math.sin(angle) * ball.speed,
},
isLaunched: true,
}
}
/**
* Main game hook with proper refs-based physics engine
*/
export function useBrickBreaker(
options: UseBrickBreakerOptions
): UseBrickBreakerReturn {
const {
config,
levels,
startLevel,
canvasDimensions,
onGameEnd,
onScoreChange,
onStateChange,
onLevelChange,
} = options
// React state for rendering (updated each frame)
const [, forceRender] = React.useReducer((x) => x + 1, 0)
// Game state stored in refs for mutation during game loop
const gameStateRef = React.useRef<GameState>('idle')
const levelIndexRef = React.useRef(startLevel - 1)
const scoreRef = React.useRef(0)
const highScoreRef = React.useRef(0)
const livesRef = React.useRef(config.gameplay.startingLives)
const comboRef = React.useRef(0)
const lastHitTimeRef = React.useRef(0)
const bricksRef = React.useRef<Brick[]>([])
const paddleRef = React.useRef<Paddle>(createPaddle(config, canvasDimensions))
const ballRef = React.useRef<Ball>(
createBall(config, canvasDimensions, paddleRef.current, levelIndexRef.current)
)
const animationFrameRef = React.useRef<number>(0)
const lastFrameTimeRef = React.useRef<number>(0)
const paddleDirectionRef = React.useRef<'left' | 'right' | 'none'>('none')
const totalBricksRef = React.useRef(0)
const destroyedBricksRef = React.useRef(0)
// Load high score
React.useEffect(() => {
if (config.storage.persistHighScore) {
highScoreRef.current = storage.get(`${config.storage.storageKey}-highscore`, 0)
}
}, [config.storage])
// Initialize level
const initLevel = React.useCallback(
(levelIndex: number) => {
const level = levels[levelIndex] || levels[0]
if (!level) return
const newBricks = createBricksFromLevel(level, config, canvasDimensions)
const newPaddle = createPaddle(config, canvasDimensions)
const newBall = createBall(config, canvasDimensions, newPaddle, levelIndex)
// Apply level speed multiplier
if (level.speedMultiplier) {
newBall.speed *= level.speedMultiplier
}
bricksRef.current = newBricks
paddleRef.current = newPaddle
ballRef.current = newBall
comboRef.current = 0
totalBricksRef.current = newBricks.filter(
(b) => b.type !== 'indestructible'
).length
destroyedBricksRef.current = 0
forceRender()
},
[config, canvasDimensions, levels]
)
// Initialize on mount
React.useEffect(() => {
initLevel(levelIndexRef.current)
}, [initLevel])
// Reinitialize on dimension change
React.useEffect(() => {
if (gameStateRef.current === 'idle') {
initLevel(levelIndexRef.current)
}
}, [canvasDimensions, initLevel])
// Game loop
const gameLoop = React.useCallback(
(timestamp: number) => {
if (gameStateRef.current !== 'playing') return
const deltaTime = timestamp - lastFrameTimeRef.current
lastFrameTimeRef.current = timestamp
// Cap delta time to prevent physics issues
const dt = Math.min(deltaTime, 50) / GAME_CONSTANTS.FRAME_TIME
const paddle = paddleRef.current
const ball = ballRef.current
const bricks = bricksRef.current
// ========== UPDATE PADDLE ==========
let newPaddleX = paddle.bounds.x
// Keyboard control
if (paddleDirectionRef.current === 'left') {
newPaddleX -= paddle.speed * dt
} else if (paddleDirectionRef.current === 'right') {
newPaddleX += paddle.speed * dt
}
// Mouse/touch control (smooth follow)
if (paddle.targetX !== null) {
const targetX = paddle.targetX - paddle.bounds.width / 2
const diff = targetX - newPaddleX
newPaddleX += diff * 0.2 * dt
}
// Clamp to bounds
newPaddleX = clamp(newPaddleX, 0, canvasDimensions.width - paddle.bounds.width)
paddle.bounds.x = newPaddleX
// ========== UPDATE BALL ==========
if (!ball.isLaunched) {
// Ball follows paddle
ball.position.x = paddle.bounds.x + paddle.bounds.width / 2
ball.position.y = paddle.bounds.y - ball.radius - 2
} else {
// Update trail
if (config.effects.showTrail) {
ball.trail.push({ ...ball.position })
if (ball.trail.length > config.effects.trailLength) {
ball.trail.shift()
}
}
// Move ball
let newX = ball.position.x + ball.velocity.x * dt
let newY = ball.position.y + ball.velocity.y * dt
let newVelX = ball.velocity.x
let newVelY = ball.velocity.y
// Wall collisions
if (newX - ball.radius < 0) {
newX = ball.radius
newVelX = Math.abs(newVelX)
} else if (newX + ball.radius > canvasDimensions.width) {
newX = canvasDimensions.width - ball.radius
newVelX = -Math.abs(newVelX)
}
if (newY - ball.radius < 0) {
newY = ball.radius
newVelY = Math.abs(newVelY)
}
ball.position.x = newX
ball.position.y = newY
ball.velocity.x = newVelX
ball.velocity.y = newVelY
// Check if ball fell below screen
if (newY - ball.radius > canvasDimensions.height) {
livesRef.current--
if (livesRef.current <= 0) {
// Game over
gameStateRef.current = 'lost'
onStateChange?.('lost')
onGameEnd?.({
won: false,
score: scoreRef.current,
highScore: Math.max(scoreRef.current, highScoreRef.current),
level: levelIndexRef.current + 1,
totalLevels: levels.length,
bricksDestroyed: destroyedBricksRef.current,
totalBricks: totalBricksRef.current,
})
} else {
// Reset ball
const newBall = createBall(
config,
canvasDimensions,
paddle,
levelIndexRef.current
)
if (levels[levelIndexRef.current]?.speedMultiplier) {
newBall.speed *= levels[levelIndexRef.current].speedMultiplier!
}
ballRef.current = newBall
comboRef.current = 0
}
forceRender()
animationFrameRef.current = requestAnimationFrame(gameLoop)
return
}
// ========== PADDLE COLLISION ==========
const paddleCollision = detectCollision(
ball.position,
ball.radius,
ball.velocity,
paddle.bounds
)
if (paddleCollision.collided && ball.velocity.y > 0) {
const resolved = resolveBallPaddleCollision(
ball,
paddle,
paddleCollision,
config
)
ball.position = resolved.position
ball.velocity = resolved.velocity
comboRef.current = 0 // Reset combo on paddle hit
}
// ========== BRICK COLLISIONS ==========
let hitBrick = false
for (const brick of bricks) {
if (brick.destroyed || hitBrick) continue
const collision = detectCollision(
ball.position,
ball.radius,
ball.velocity,
brick.bounds
)
if (collision.collided) {
hitBrick = true
// Resolve collision
const resolved = resolveBallBrickCollision(ball, brick, collision, config)
ball.position = resolved.position
ball.velocity = resolved.velocity
// Damage brick
if (brick.type !== 'indestructible') {
brick.health--
if (brick.health <= 0) {
brick.destroyed = true
brick.destroyedAt = Date.now()
destroyedBricksRef.current++
// Update combo
const now = Date.now()
if (now - lastHitTimeRef.current < config.scoring.comboTimeout) {
comboRef.current = Math.min(
comboRef.current + 1,
config.scoring.maxCombo
)
} else {
comboRef.current = 1
}
lastHitTimeRef.current = now
// Calculate score with combo
const multiplier = 1 + comboRef.current * config.scoring.comboMultiplier
const points = Math.floor(brick.points * multiplier)
scoreRef.current += points
// Update high score
if (scoreRef.current > highScoreRef.current) {
highScoreRef.current = scoreRef.current
if (config.storage.persistHighScore) {
storage.set(
`${config.storage.storageKey}-highscore`,
highScoreRef.current
)
}
}
onScoreChange?.(scoreRef.current, comboRef.current)
}
}
break // Only handle one collision per frame
}
}
// ========== CHECK WIN CONDITION ==========
const remainingBricks = bricks.filter(
(b) => !b.destroyed && b.type !== 'indestructible'
)
if (remainingBricks.length === 0) {
// Level complete!
scoreRef.current += config.scoring.levelBonus
onScoreChange?.(scoreRef.current, comboRef.current)
if (levelIndexRef.current >= levels.length - 1) {
// Game won!
gameStateRef.current = 'won'
onStateChange?.('won')
onGameEnd?.({
won: true,
score: scoreRef.current,
highScore: Math.max(scoreRef.current, highScoreRef.current),
level: levelIndexRef.current + 1,
totalLevels: levels.length,
bricksDestroyed: destroyedBricksRef.current,
totalBricks: totalBricksRef.current,
})
} else {
// Next level
gameStateRef.current = 'levelComplete'
onStateChange?.('levelComplete')
}
forceRender()
return
}
}
forceRender()
animationFrameRef.current = requestAnimationFrame(gameLoop)
},
[config, canvasDimensions, levels, onGameEnd, onScoreChange, onStateChange]
)
// Start/stop loop based on state
React.useEffect(() => {
if (gameStateRef.current === 'playing') {
lastFrameTimeRef.current = performance.now()
animationFrameRef.current = requestAnimationFrame(gameLoop)
}
return () => {
cancelAnimationFrame(animationFrameRef.current)
}
}, [gameLoop])
// ========== PUBLIC ACTIONS ==========
const startGame = React.useCallback(() => {
if (gameStateRef.current === 'idle' || gameStateRef.current === 'lost') {
levelIndexRef.current = startLevel - 1
scoreRef.current = 0
livesRef.current = config.gameplay.startingLives
destroyedBricksRef.current = 0
initLevel(levelIndexRef.current)
}
if (!ballRef.current.isLaunched) {
ballRef.current = launchBallFromPaddle(ballRef.current)
}
gameStateRef.current = 'playing'
onStateChange?.('playing')
lastFrameTimeRef.current = performance.now()
animationFrameRef.current = requestAnimationFrame(gameLoop)
forceRender()
}, [config.gameplay.startingLives, startLevel, initLevel, onStateChange, gameLoop])
const pauseGame = React.useCallback(() => {
if (gameStateRef.current === 'playing') {
gameStateRef.current = 'paused'
onStateChange?.('paused')
cancelAnimationFrame(animationFrameRef.current)
forceRender()
}
}, [onStateChange])
const resumeGame = React.useCallback(() => {
if (gameStateRef.current === 'paused') {
gameStateRef.current = 'playing'
onStateChange?.('playing')
lastFrameTimeRef.current = performance.now()
animationFrameRef.current = requestAnimationFrame(gameLoop)
forceRender()
}
}, [onStateChange, gameLoop])
const resetGame = React.useCallback(() => {
cancelAnimationFrame(animationFrameRef.current)
levelIndexRef.current = startLevel - 1
scoreRef.current = 0
livesRef.current = config.gameplay.startingLives
destroyedBricksRef.current = 0
gameStateRef.current = 'idle'
initLevel(levelIndexRef.current)
onStateChange?.('idle')
forceRender()
}, [config.gameplay.startingLives, startLevel, initLevel, onStateChange])
const nextLevel = React.useCallback(() => {
if (
gameStateRef.current === 'levelComplete' &&
levelIndexRef.current < levels.length - 1
) {
levelIndexRef.current++
initLevel(levelIndexRef.current)
onLevelChange?.(levelIndexRef.current + 1)
// Auto-launch after brief pause
gameStateRef.current = 'playing'
onStateChange?.('playing')
lastFrameTimeRef.current = performance.now()
animationFrameRef.current = requestAnimationFrame(gameLoop)
forceRender()
}
}, [levels.length, initLevel, onLevelChange, onStateChange, gameLoop])
const movePaddle = React.useCallback((direction: 'left' | 'right' | 'none') => {
paddleDirectionRef.current = direction
paddleRef.current.targetX = null
}, [])
const setPaddlePosition = React.useCallback((x: number) => {
paddleRef.current.targetX = x
paddleDirectionRef.current = 'none'
}, [])
const launchBallAction = React.useCallback(() => {
if (!ballRef.current.isLaunched && gameStateRef.current === 'playing') {
ballRef.current = launchBallFromPaddle(ballRef.current)
forceRender()
}
}, [])
// Create snapshot for rendering
const currentLevel = levels[levelIndexRef.current] || levels[0]
const snapshot: GameSnapshot = {
state: gameStateRef.current,
level: levelIndexRef.current + 1,
levelName: currentLevel?.name || `Level ${levelIndexRef.current + 1}`,
score: scoreRef.current,
highScore: highScoreRef.current,
lives: livesRef.current,
bricks: bricksRef.current,
paddle: paddleRef.current,
ball: ballRef.current,
combo: comboRef.current,
totalLevels: levels.length,
}
return {
snapshot,
startGame,
pauseGame,
resumeGame,
resetGame,
nextLevel,
movePaddle,
setPaddlePosition,
launchBall: launchBallAction,
}
}