shadimage
joyco
componentspace-invaderssource: ok · hit

Space Invaders

A vertical-scrolling shoot-'em-up with enemy waves, a scaling boss (REBEL CRUSHER), pause, and highscore persistence.

npx shadcn add "https://registry.joyco.studio/r/space-invaders.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

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

Files9 · source cached 10/8/2026, 1:40:01 PM

registry/components/games/space-invaders/index.ts
export { SpaceInvaders } from './space-invaders'
export { useSpaceInvaders } from './use-space-invaders'
export type { UseSpaceInvadersReturn } from './use-space-invaders'
export { DEFAULT_CONFIG, DEFAULT_COLORS, GAME_CONSTANTS, KEY_BINDINGS, CANVAS } from './config'
export { mergeConfig, resolveCssColor, formatScore, storage } from './utils'
export type {
  SpaceInvadersProps,
  SpaceInvadersConfig,
  SpaceInvadersColors,
  GameState,
  HudState,
  DeepPartial,
  World,
  Enemy,
  Boss,
  Bullet,
  Particle,
  Player,
} from './types'
export { renderWorld } from './render'
export type { ResolvedColors } from './render'
registry/components/games/space-invaders/space-invaders.tsx
'use client'

import * as React from 'react'
import { cn } from '@/lib/utils'
import { CANVAS, DEFAULT_CONFIG } from './config'
import { mergeConfig, resolveCssColor, formatScore } from './utils'
import { useSpaceInvaders } from './use-space-invaders'
import { SpaceInvadersUI } from './ui'
import type { ResolvedColors } from './render'
import type { SpaceInvadersConfig, SpaceInvadersProps } from './types'

function resolveColors(config: SpaceInvadersConfig, el: HTMLElement): ResolvedColors {
  const c = config.colors
  return {
    background: resolveCssColor(c.background, el),
    player: resolveCssColor(c.player, el),
    playerBullet: resolveCssColor(c.playerBullet, el),
    enemyBullet: resolveCssColor(c.enemyBullet, el),
    enemy: resolveCssColor(c.enemy, el),
    particle: resolveCssColor(c.particle, el),
    boss: resolveCssColor(c.boss, el),
    bossHit: resolveCssColor(c.bossHit, el),
    exhaust: [
      resolveCssColor(c.exhaust[0], el),
      resolveCssColor(c.exhaust[1], el),
      resolveCssColor(c.exhaust[2], el),
    ],
  }
}

const FALLBACK_COLORS: ResolvedColors = {
  background: '#000',
  player: '#fff',
  playerBullet: '#22c55e',
  enemyBullet: '#ef4444',
  enemy: '#9ca3af',
  particle: '#9ca3af',
  boss: '#dc2626',
  bossHit: '#fff',
  exhaust: ['#fde047', '#fb923c', '#ef4444'],
}

export function SpaceInvaders({
  config: configOverrides,
  showControls = true,
  onScoreChange,
  onStateChange,
  onGameEnd,
  className,
}: SpaceInvadersProps): React.ReactElement {
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  const colorsRef = React.useRef<ResolvedColors>(FALLBACK_COLORS)
  const [theme, setTheme] = React.useState('')

  const config = React.useMemo<SpaceInvadersConfig>(
    () => mergeConfig(DEFAULT_CONFIG, configOverrides),
    [configOverrides]
  )

  React.useEffect(() => {
    const canvas = canvasRef.current
    if (canvas) colorsRef.current = resolveColors(config, canvas)
  }, [config, theme])

  React.useEffect(() => {
    const html = document.documentElement
    setTheme(html.className)
    const observer = new MutationObserver(() => setTheme(html.className))
    observer.observe(html, { attributes: true, attributeFilter: ['class'] })
    return () => observer.disconnect()
  }, [])

  const getColors = React.useCallback(() => colorsRef.current, [])

  const { hud, start, togglePause, attachCanvas } = useSpaceInvaders({
    config,
    getColors,
    onScoreChange,
    onStateChange,
    onGameEnd,
  })

  React.useEffect(() => {
    attachCanvas(canvasRef.current)
    return () => attachCanvas(null)
  }, [attachCanvas])

  const showOverlay = hud.phase !== 'playing'

  return (
    <div
      data-slot="space-invaders"
      data-state={hud.phase}
      className={cn('bg-background flex w-full flex-col items-center', className)}
    >
      <div className="bg-muted relative w-full overflow-hidden" style={{ aspectRatio: '3 / 4' }}>
        <canvas
          ref={canvasRef}
          width={CANVAS.width}
          height={CANVAS.height}
          className="block size-full"
          style={{ imageRendering: 'pixelated' }}
          role="img"
          aria-label={`Space Invaders — ${hud.phase === 'playing' ? `score ${hud.score}` : hud.phase}`}
        />

        {hud.phase !== 'idle' && (
          <div className="text-muted-foreground pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between gap-1.5 px-2.5 pt-2 font-mono text-[10px] whitespace-nowrap uppercase">
            <span>
              <span className="text-muted-foreground/40">[ </span>
              <span className="text-foreground">Lives</span> {'▲'.repeat(Math.max(0, hud.lives))}
              <span className="text-muted-foreground/40"> ]</span>
            </span>
            <span>
              <span className="text-muted-foreground/40">[ </span>
              <span className="text-foreground">Score</span> {formatScore(hud.score)}
              <span className="text-muted-foreground/40"> ]</span>
            </span>
            <span>
              <span className="text-muted-foreground/40">[ </span>
              <span className="text-foreground">Lvl</span> {hud.level}
              <span className="text-muted-foreground/40"> ]</span>
            </span>
            <span>
              <span className="text-muted-foreground/40">[ </span>
              <span className="text-foreground">Hi</span> {hud.highScore.toLocaleString()}
              <span className="text-muted-foreground/40"> ]</span>
            </span>
          </div>
        )}

        {showOverlay && (
          <div className="bg-background/85 absolute inset-0 flex flex-col items-center justify-center gap-3">
            {hud.phase === 'idle' &&
              (hud.highScore > 0 ? (
                <div className="flex flex-col items-center gap-1.5">
                  <span className="text-muted-foreground/50 text-[10px] uppercase tracking-[0.35em]">
                    High Score
                  </span>
                  <span className="text-foreground text-3xl tabular-nums tracking-[0.1em]">
                    {hud.highScore.toLocaleString()}
                  </span>
                </div>
              ) : (
                <p className="text-muted-foreground/70 text-xs uppercase tracking-[0.2em]">
                  No highscore record — play now
                </p>
              ))}
            {hud.phase === 'paused' && (
              <p className="text-muted-foreground text-xs uppercase tracking-[0.3em]">Paused</p>
            )}
            {hud.phase === 'gameOver' && (
              <>
                <p className="text-foreground text-lg uppercase tracking-[0.3em]">Game Over</p>
                <p className="text-muted-foreground text-sm">Score {hud.score.toLocaleString()}</p>
              </>
            )}
          </div>
        )}
      </div>

      {showControls && (
        <SpaceInvadersUI state={hud.phase} score={hud.score} onStart={start} onResume={togglePause} />
      )}
    </div>
  )
}
registry/components/games/space-invaders/ui.tsx
'use client'

import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import type { GameState } from './types'

interface SpaceInvadersUIProps {
  state: GameState
  score: number
  onStart: () => void
  onResume: () => void
}

const MOVE_KEYS: ReadonlyArray<{ desktop: string; mobile: string }> = [
  { desktop: 'W', mobile: '↑' },
  { desktop: 'A', mobile: '←' },
  { desktop: 'S', mobile: '↓' },
  { desktop: 'D', mobile: '→' },
]

export function SpaceInvadersUI({ state, score, onStart, onResume }: SpaceInvadersUIProps): React.ReactElement {
  return (
    <div
      data-slot="space-invaders-controls"
      className="text-muted-foreground border-background flex w-full items-center justify-center border-t-4 font-mono text-xs"
    >
      {state === 'gameOver' ? (
        <div className="flex w-full items-center gap-1 pl-2">
          <span className="flex-1 uppercase">
            Score: <span className="text-foreground font-bold">{score}</span>
          </span>
          <Button onClick={onStart} size="sm" className="h-8 rounded-none text-xs font-medium uppercase">
            Play again
          </Button>
        </div>
      ) : state === 'paused' ? (
        <Button onClick={onResume} size="sm" className="h-8 w-full rounded-none text-xs font-medium uppercase">
          Resume
        </Button>
      ) : state === 'playing' ? (
        <div className="flex h-8 w-full items-center gap-1 px-2">
          <span className="ml-auto flex items-center gap-0.5">
            {MOVE_KEYS.map(({ desktop, mobile }) => (
              <Kbd key={desktop} className="rounded-none text-[10px]">
                <span className="max-md:hidden">{desktop}</span>
                <span className="max-md:inline md:hidden">{mobile}</span>
              </Kbd>
            ))}
            <Kbd className="ml-1 rounded-none text-[10px]">Space</Kbd>
            <Kbd className="rounded-none text-[10px]">Esc</Kbd>
          </span>
        </div>
      ) : (
        <Button onClick={onStart} size="sm" className="h-8 w-full rounded-none text-xs font-medium uppercase">
          Start Game
        </Button>
      )}
    </div>
  )
}
registry/components/games/space-invaders/use-space-invaders.ts
'use client'

import * as React from 'react'
import { createWorld, skipToNextLevel, startLoop, stepWorld, type LoopHandle } from './engine'
import { renderWorld, type ResolvedColors } from './render'
import { storage } from './utils'
import { GAME_CONSTANTS, KEY_BINDINGS } from './config'
import type { SpaceInvadersConfig, GameState, HudState, InputState, World } from './types'

function emptyInput(): InputState {
  return { up: false, down: false, left: false, right: false, fire: false }
}

function hudFromWorld(world: World): HudState {
  return {
    score: world.score,
    lives: world.lives,
    level: world.level,
    highScore: world.highScore,
    phase: world.phase,
  }
}

interface UseSpaceInvadersOptions {
  config: SpaceInvadersConfig
  getColors: () => ResolvedColors
  onScoreChange?: (score: number) => void
  onStateChange?: (state: GameState) => void
  onGameEnd?: (result: { score: number; level: number }) => void
}

export interface UseSpaceInvadersReturn {
  hud: HudState
  start: () => void
  togglePause: () => void
  attachCanvas: (canvas: HTMLCanvasElement | null) => void
}

export function useSpaceInvaders({
  config,
  getColors,
  onScoreChange,
  onStateChange,
  onGameEnd,
}: UseSpaceInvadersOptions): UseSpaceInvadersReturn {
  const [hud, setHud] = React.useState<HudState>({
    score: 0,
    lives: config.player.startLives,
    level: 1,
    highScore: storage.getHighScore(),
    phase: 'idle',
  })

  const worldRef = React.useRef<World | null>(null)
  const ctxRef = React.useRef<CanvasRenderingContext2D | null>(null)
  const loopRef = React.useRef<LoopHandle | null>(null)
  const inputRef = React.useRef<InputState>(emptyInput())
  const lastHudRef = React.useRef<HudState>(hud)
  const configRef = React.useRef(config)
  const getColorsRef = React.useRef(getColors)
  const cbRef = React.useRef({ onScoreChange, onStateChange, onGameEnd })

  React.useEffect(() => {
    configRef.current = config
  }, [config])
  React.useEffect(() => {
    getColorsRef.current = getColors
  }, [getColors])
  React.useEffect(() => {
    cbRef.current = { onScoreChange, onStateChange, onGameEnd }
  }, [onScoreChange, onStateChange, onGameEnd])

  const pushHudIfChanged = React.useCallback((world: World): void => {
    const next = hudFromWorld(world)
    const prev = lastHudRef.current
    if (
      next.score !== prev.score ||
      next.lives !== prev.lives ||
      next.level !== prev.level ||
      next.highScore !== prev.highScore ||
      next.phase !== prev.phase
    ) {
      const cbs = cbRef.current
      if (next.score !== prev.score) cbs.onScoreChange?.(next.score)
      if (next.phase !== prev.phase) cbs.onStateChange?.(next.phase)
      if (next.phase === 'gameOver' && prev.phase !== 'gameOver') {
        storage.setHighScore(next.highScore)
        cbs.onGameEnd?.({ score: next.score, level: next.level })
      }
      lastHudRef.current = next
      setHud(next)
    }
  }, [])

  const attachCanvas = React.useCallback((canvas: HTMLCanvasElement | null): void => {
    ctxRef.current = canvas ? canvas.getContext('2d') : null
  }, [])

  const togglePause = React.useCallback((): void => {
    const world = worldRef.current
    if (!world) return
    if (world.phase === 'playing') world.phase = 'paused'
    else if (world.phase === 'paused') world.phase = 'playing'
    pushHudIfChanged(world)
  }, [pushHudIfChanged])

  const start = React.useCallback((): void => {
    const world = createWorld(storage.getHighScore(), configRef.current)
    worldRef.current = world
    lastHudRef.current = hudFromWorld(world)
    inputRef.current = emptyInput()
    cbRef.current.onStateChange?.('playing')
    setHud(lastHudRef.current)
  }, [])

  React.useEffect(() => {
    const matches = (codes: readonly string[], code: string) => codes.includes(code)
    const setKey = (code: string, pressed: boolean): boolean => {
      const input = inputRef.current
      if (matches(KEY_BINDINGS.up, code)) {
        input.up = pressed
        return true
      }
      if (matches(KEY_BINDINGS.down, code)) {
        input.down = pressed
        return true
      }
      if (matches(KEY_BINDINGS.left, code)) {
        input.left = pressed
        return true
      }
      if (matches(KEY_BINDINGS.right, code)) {
        input.right = pressed
        return true
      }
      if (matches(KEY_BINDINGS.fire, code)) {
        input.fire = pressed
        return true
      }
      return false
    }

    const onKeyDown = (e: KeyboardEvent): void => {
      if (matches(KEY_BINDINGS.pause, e.code)) {
        togglePause()
        e.preventDefault()
        return
      }
      // Dev-only level skip (stripped from production builds).
      if (process.env.NODE_ENV === 'development' && matches(KEY_BINDINGS.skip, e.code)) {
        const world = worldRef.current
        if (world && world.phase === 'playing') {
          skipToNextLevel(world, configRef.current)
          pushHudIfChanged(world)
        }
        e.preventDefault()
        return
      }
      if (setKey(e.code, true)) e.preventDefault()
    }
    const onKeyUp = (e: KeyboardEvent): void => {
      if (setKey(e.code, false)) e.preventDefault()
    }
    const onVisibility = (): void => {
      const world = worldRef.current
      if (document.hidden && world && world.phase === 'playing') {
        world.phase = 'paused'
        pushHudIfChanged(world)
      }
    }

    window.addEventListener('keydown', onKeyDown)
    window.addEventListener('keyup', onKeyUp)
    document.addEventListener('visibilitychange', onVisibility)

    loopRef.current = startLoop(
      (fixedStep) => {
        const world = worldRef.current
        if (world && world.phase === 'playing') {
          stepWorld(world, inputRef.current, fixedStep, configRef.current)
          pushHudIfChanged(world)
        }
      },
      () => {
        const world = worldRef.current
        const ctx = ctxRef.current
        if (world && ctx) renderWorld(ctx, world, getColorsRef.current())
      },
      GAME_CONSTANTS.fixedStep,
      GAME_CONSTANTS.maxStepsPerFrame
    )

    return () => {
      loopRef.current?.stop()
      window.removeEventListener('keydown', onKeyDown)
      window.removeEventListener('keyup', onKeyUp)
      document.removeEventListener('visibilitychange', onVisibility)
    }
  }, [togglePause, pushHudIfChanged])

  return { hud, start, togglePause, attachCanvas }
}
registry/components/games/space-invaders/engine.ts
import { CANVAS, GAME_CONSTANTS } from './config'
import type {
  Boss,
  Bullet,
  Enemy,
  EnemyKind,
  SpaceInvadersConfig,
  InputState,
  Particle,
  Player,
  World,
} from './types'

// ---- Shared helpers ------------------------------------------------------

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

// ---- Collision -----------------------------------------------------------

/** Axis-aligned bounding-box overlap for center-positioned entities. Edge-touch is not an overlap. */
export function aabbOverlap(
  a: { x: number; y: number; width: number; height: number },
  b: { x: number; y: number; width: number; height: number },
): boolean {
  return (
    Math.abs(a.x - b.x) * 2 < a.width + b.width &&
    Math.abs(a.y - b.y) * 2 < a.height + b.height
  )
}

// ---- Loop ----------------------------------------------------------------

export interface StepResult {
  steps: number
  remainder: number
}

/**
 * Given an accumulated time budget, return how many fixed steps to run and the
 * leftover time. Clamps to maxSteps and discards excess to avoid a spiral of
 * death after the tab is backgrounded.
 */
export function computeSteps(accumulator: number, fixedStep: number, maxSteps: number): StepResult {
  let steps = Math.floor(accumulator / fixedStep)
  if (steps > maxSteps) {
    return { steps: maxSteps, remainder: 0 }
  }
  if (steps < 0) {
    steps = 0
  }
  return { steps, remainder: accumulator - steps * fixedStep }
}

export interface LoopHandle {
  stop: () => void
}

/**
 * Run `onStep(fixedStep)` at a fixed cadence and `onRender()` once per
 * animation frame. Clamps delta to avoid a spiral of death after backgrounding.
 */
export function startLoop(
  onStep: (fixedStep: number) => void,
  onRender: () => void,
  fixedStep: number,
  maxSteps: number,
): LoopHandle {
  let accumulator = 0
  let last = performance.now()
  let rafId = 0
  let running = true

  const frame = (now: number): void => {
    if (!running) return
    accumulator += (now - last) / 1000
    last = now

    const { steps, remainder } = computeSteps(accumulator, fixedStep, maxSteps)
    for (let i = 0; i < steps; i += 1) {
      onStep(fixedStep)
    }
    accumulator = remainder

    onRender()
    rafId = requestAnimationFrame(frame)
  }

  rafId = requestAnimationFrame(frame)

  return {
    stop: () => {
      running = false
      cancelAnimationFrame(rafId)
    },
  }
}

// ---- Spawner -------------------------------------------------------------

function enemiesForLevel(level: number, config: SpaceInvadersConfig): number {
  return config.spawn.baseEnemies + (level - 1) * config.spawn.enemiesPerLevel
}

function spawnIntervalForLevel(level: number, config: SpaceInvadersConfig): number {
  const interval = config.spawn.baseInterval - (level - 1) * config.spawn.rampPerLevel
  return Math.max(config.spawn.minInterval, interval)
}

function enemyFireInterval(level: number, config: SpaceInvadersConfig): number {
  const interval = config.enemy.fireIntervalBase - (level - 1) * config.enemy.fireIntervalRamp
  return Math.max(config.enemy.fireIntervalMin, interval)
}

/**
 * Deterministic enemy-kind selection based on level and the enemy's index in
 * the wave. Determinism keeps spawning unit-testable (no Math.random).
 */
function pickEnemyKind(level: number, indexInWave: number): EnemyKind {
  if (level <= 1) {
    return 'grunt'
  }
  if (level >= 4 && indexInWave % 5 === 4) {
    return 'tank'
  }
  if (indexInWave % 3 === 1) {
    return 'weaver'
  }
  return 'grunt'
}

// ---- Player --------------------------------------------------------------

function createPlayer(config: SpaceInvadersConfig): Player {
  return {
    x: config.player.startX,
    y: config.player.startY,
    width: config.player.width,
    height: config.player.height,
    fireCooldown: 0,
    invincibleFor: config.player.invincibleTime,
    muzzleFlash: 0,
  }
}

/** Advances the player and returns any bullets fired this step. Mutates `player`. */
function updatePlayer(player: Player, input: InputState, dt: number, config: SpaceInvadersConfig): Bullet[] {
  const dx = (input.right ? 1 : 0) - (input.left ? 1 : 0)
  const dy = (input.down ? 1 : 0) - (input.up ? 1 : 0)
  player.x += dx * config.player.speed * dt
  player.y += dy * config.player.speed * dt

  const halfW = player.width / 2
  const halfH = player.height / 2
  player.x = clamp(player.x, halfW, CANVAS.width - halfW)
  player.y = clamp(player.y, halfH, CANVAS.height - halfH)

  if (player.invincibleFor > 0) {
    player.invincibleFor = Math.max(0, player.invincibleFor - dt)
  }
  if (player.fireCooldown > 0) {
    player.fireCooldown = Math.max(0, player.fireCooldown - dt)
  }
  if (player.muzzleFlash > 0) {
    player.muzzleFlash = Math.max(0, player.muzzleFlash - dt)
  }

  const bullets: Bullet[] = []
  if (input.fire && player.fireCooldown === 0) {
    bullets.push({
      x: player.x,
      y: player.y - halfH,
      width: config.bullet.width,
      height: config.bullet.height,
      vx: 0,
      vy: -config.player.bulletSpeed,
      owner: 'player',
    })
    player.fireCooldown = config.player.fireInterval
    player.muzzleFlash = config.player.muzzleFlashTime
  }
  return bullets
}

/** Resets position and grants invincibility after losing a life. Mutates `player`. */
function respawnPlayer(player: Player, config: SpaceInvadersConfig): void {
  player.x = config.player.startX
  player.y = config.player.startY
  player.fireCooldown = 0
  player.invincibleFor = config.player.invincibleTime
}

// ---- Bullets -------------------------------------------------------------

/** Advances bullets and returns the list with off-screen bullets removed. Mutates positions. */
function updateBullets(bullets: Bullet[], dt: number): Bullet[] {
  for (const b of bullets) {
    b.x += b.vx * dt
    b.y += b.vy * dt
  }
  const margin = 40
  const xMargin = 60
  return bullets.filter(
    (b) =>
      b.y > -margin &&
      b.y < CANVAS.height + margin &&
      b.x > -xMargin &&
      b.x < CANVAS.width + xMargin,
  )
}

// ---- Enemies -------------------------------------------------------------

function statsFor(
  kind: EnemyKind,
  config: SpaceInvadersConfig,
): { width: number; height: number; hp: number; speedY: number } {
  switch (kind) {
    case 'weaver':
      return config.enemy.weaver
    case 'tank':
      return config.enemy.tank
    case 'grunt':
    default:
      return config.enemy.grunt
  }
}

function createEnemy(kind: EnemyKind, spawnX: number, level: number, config: SpaceInvadersConfig): Enemy {
  const stats = statsFor(kind, config)
  return {
    kind,
    x: spawnX,
    y: -stats.height,
    width: stats.width,
    height: stats.height,
    hp: stats.hp,
    speedY: stats.speedY,
    age: 0,
    spawnX,
    fireCooldown: enemyFireInterval(level, config),
  }
}

/** Advances the enemy and returns a fired bullet or null. Mutates `enemy`. */
function updateEnemy(
  enemy: Enemy,
  level: number,
  dt: number,
  config: SpaceInvadersConfig,
): Bullet | null {
  enemy.age += dt
  enemy.y += enemy.speedY * dt

  if (enemy.kind === 'weaver') {
    const { amplitude, frequency } = config.enemy.weaver
    const halfW = enemy.width / 2
    const offset = Math.sin(enemy.age * frequency) * amplitude
    enemy.x = clamp(enemy.spawnX + offset, halfW, CANVAS.width - halfW)
  }

  enemy.fireCooldown -= dt
  if (enemy.fireCooldown > 0) {
    return null
  }
  enemy.fireCooldown = enemyFireInterval(level, config)

  return {
    x: enemy.x,
    y: enemy.y + enemy.height / 2,
    width: config.bullet.width,
    height: config.bullet.height,
    vx: 0,
    vy: config.enemy.bulletSpeed,
    owner: 'enemy',
  }
}

// ---- Particles -----------------------------------------------------------

function createExplosion(x: number, y: number, config: SpaceInvadersConfig): Particle[] {
  const count = config.particle.countPerExplosion
  const sizeRange = config.particle.sizeMax - config.particle.sizeMin
  const particles: Particle[] = []
  for (let i = 0; i < count; i += 1) {
    const angle = (i / count) * Math.PI * 2
    // Deterministic per-index variety (no RNG keeps explosions unit-testable):
    // golden-ratio sequences scatter speed, size, and shade so the burst reads
    // as chaotic grayscale debris rather than a uniform ring.
    const speedMul = 0.45 + ((i * 0.6180339887) % 1) * 0.9 // 0.45..1.35
    const size = config.particle.sizeMin + ((i * 7) % (sizeRange + 1))
    const shade = (i * 0.3819660113) % 1 // 0..1
    particles.push({
      x,
      y,
      vx: Math.cos(angle) * config.particle.speed * speedMul,
      vy: Math.sin(angle) * config.particle.speed * speedMul,
      life: config.particle.life,
      maxLife: config.particle.life,
      size,
      shade,
    })
  }
  return particles
}

/** Advances particles and returns the list with dead ones removed. Mutates positions/life. */
function updateParticles(particles: Particle[], dt: number): Particle[] {
  for (const p of particles) {
    p.x += p.vx * dt
    p.y += p.vy * dt
    p.life -= dt
  }
  return particles.filter((p) => p.life > 0)
}

// ---- Boss ----------------------------------------------------------------

export function tierForLevel(level: number): number {
  return Math.max(1, Math.floor(level / GAME_CONSTANTS.bossEveryLevels))
}

export function createBoss(level: number, config: SpaceInvadersConfig): Boss {
  const tier = tierForLevel(level)
  const maxHp = config.boss.baseHp + (tier - 1) * config.boss.hpPerTier
  const fireInterval = Math.max(
    config.boss.fireIntervalMin,
    config.boss.fireIntervalBase - (tier - 1) * config.boss.fireIntervalRamp,
  )
  return {
    x: CANVAS.width / 2,
    y: config.boss.marginY + config.boss.height / 2,
    width: config.boss.width,
    height: config.boss.height,
    hp: maxHp,
    maxHp,
    dirX: 1,
    age: 0,
    fireCooldown: fireInterval,
    fireInterval,
    patternTimer: config.boss.patternSwitch,
    pattern: 'aimed',
    hitFlash: 0,
  }
}

function bullet(x: number, y: number, vx: number, vy: number, config: SpaceInvadersConfig): Bullet {
  return {
    x,
    y,
    width: config.bullet.width,
    height: config.bullet.height,
    vx,
    vy,
    owner: 'enemy',
  }
}

function aimedVolley(boss: Boss, player: Player, config: SpaceInvadersConfig): Bullet[] {
  const muzzleY = boss.y + boss.height / 2
  const dx = player.x - boss.x
  const dy = player.y - muzzleY
  const baseAngle = Math.atan2(dy, dx) + (Math.random() - 0.5) * config.boss.aimJitter
  const { aimedCount, aimedSpread, bulletSpeed } = config.boss
  const bullets: Bullet[] = []
  const denom = Math.max(1, aimedCount - 1)
  for (let i = 0; i < aimedCount; i += 1) {
    const t = (i - (aimedCount - 1) / 2) / denom // centered: -0.5..0.5 (0 when single)
    const angle = baseAngle + t * aimedSpread
    bullets.push(bullet(boss.x, muzzleY, Math.cos(angle) * bulletSpeed, Math.sin(angle) * bulletSpeed, config))
  }
  return bullets
}

function radialVolley(boss: Boss, config: SpaceInvadersConfig): Bullet[] {
  const muzzleY = boss.y + boss.height / 2
  const { radialCount, bulletSpeed } = config.boss
  const bullets: Bullet[] = []
  for (let i = 0; i < radialCount; i += 1) {
    const angle = (i / radialCount) * Math.PI * 2
    bullets.push(bullet(boss.x, muzzleY, Math.cos(angle) * bulletSpeed, Math.sin(angle) * bulletSpeed, config))
  }
  return bullets
}

/** Advances the boss and returns any bullets fired this step. Mutates `boss`. */
function updateBoss(boss: Boss, player: Player, dt: number, config: SpaceInvadersConfig): Bullet[] {
  boss.age += dt
  if (boss.hitFlash > 0) {
    boss.hitFlash = Math.max(0, boss.hitFlash - dt)
  }

  // Side-to-side movement, bouncing off the playfield edges.
  const halfW = boss.width / 2
  boss.x += boss.dirX * config.boss.speedX * dt
  if (boss.x <= halfW) {
    boss.x = halfW
    boss.dirX = 1
  } else if (boss.x >= CANVAS.width - halfW) {
    boss.x = CANVAS.width - halfW
    boss.dirX = -1
  }
  boss.x = clamp(boss.x, halfW, CANVAS.width - halfW)

  // Pattern switching.
  boss.patternTimer -= dt
  if (boss.patternTimer <= 0) {
    boss.pattern = boss.pattern === 'aimed' ? 'radial' : 'aimed'
    boss.patternTimer = config.boss.patternSwitch
  }

  // Firing.
  boss.fireCooldown -= dt
  if (boss.fireCooldown > 0) {
    return []
  }
  boss.fireCooldown = boss.fireInterval
  return boss.pattern === 'aimed' ? aimedVolley(boss, player, config) : radialVolley(boss, config)
}

// ---- World ---------------------------------------------------------------

function scoreForKind(kind: EnemyKind, config: SpaceInvadersConfig): number {
  switch (kind) {
    case 'weaver':
      return config.enemy.weaver.score
    case 'tank':
      return config.enemy.tank.score
    case 'grunt':
    default:
      return config.enemy.grunt.score
  }
}

/** Configures `world` for `level`: a boss-only level or a normal wave. Mutates `world`. */
function startLevel(world: World, level: number, config: SpaceInvadersConfig): void {
  world.level = level
  if (level % GAME_CONSTANTS.bossEveryLevels === 0) {
    world.boss = createBoss(level, config)
    world.enemiesToSpawn = 0
    world.spawnTimer = 0
  } else {
    world.boss = null
    world.enemiesToSpawn = enemiesForLevel(level, config)
    world.spawnTimer = spawnIntervalForLevel(level, config)
  }
}

/** Debug helper: clears the field and jumps to the next level immediately. Mutates `world`. */
export function skipToNextLevel(world: World, config: SpaceInvadersConfig): void {
  if (world.phase !== 'playing') return
  world.enemies = []
  world.boss = null
  world.bullets = world.bullets.filter((b) => b.owner === 'player')
  startLevel(world, world.level + 1, config)
}

export function createWorld(highScore: number, config: SpaceInvadersConfig): World {
  const world: World = {
    phase: 'playing',
    player: createPlayer(config),
    bullets: [],
    enemies: [],
    particles: [],
    boss: null,
    score: 0,
    lives: config.player.startLives,
    level: 1,
    highScore,
    spawnTimer: 0,
    enemiesToSpawn: 0,
    frame: 0,
  }
  startLevel(world, 1, config)
  return world
}

function spawnX(level: number, index: number): number {
  // Deterministic spread across the playfield width (golden-ratio, no RNG).
  const margin = 60
  const span = CANVAS.width - margin * 2
  const t = (((index * 0.618 + level * 0.137) % 1) + 1) % 1
  return margin + t * span
}

function loseLife(world: World, config: SpaceInvadersConfig): void {
  world.lives -= 1
  world.particles.push(...createExplosion(world.player.x, world.player.y, config))
  if (world.lives <= 0) {
    world.phase = 'gameOver'
    if (world.score > world.highScore) {
      world.highScore = world.score
    }
    return
  }
  respawnPlayer(world.player, config)
}

/** Advances the world by one fixed step. Mutates `world`. */
export function stepWorld(world: World, input: InputState, dt: number, config: SpaceInvadersConfig): void {
  if (world.phase !== 'playing') {
    return
  }

  world.frame += 1 // advances sprite/flame animation

  // Player + player bullets.
  world.bullets.push(...updatePlayer(world.player, input, dt, config))

  // Spawn enemies over time.
  if (world.enemiesToSpawn > 0) {
    world.spawnTimer -= dt
    if (world.spawnTimer <= 0) {
      const index = enemiesForLevel(world.level, config) - world.enemiesToSpawn
      const kind = pickEnemyKind(world.level, index)
      world.enemies.push(createEnemy(kind, spawnX(world.level, index), world.level, config))
      world.enemiesToSpawn -= 1
      world.spawnTimer = spawnIntervalForLevel(world.level, config)
    }
  }

  // Enemies + enemy bullets.
  for (const enemy of world.enemies) {
    const fired = updateEnemy(enemy, world.level, dt, config)
    if (fired) {
      world.bullets.push(fired)
    }
  }
  // Cull enemies that left the bottom.
  world.enemies = world.enemies.filter((e) => e.y - e.height / 2 < CANVAS.height + 40)

  // Boss: move, fire, and feed its bullets into the shared bullet list.
  if (world.boss) {
    world.bullets.push(...updateBoss(world.boss, world.player, dt, config))
  }

  // Move + cull off-screen bullets.
  world.bullets = updateBullets(world.bullets, dt)

  // Player bullets vs enemies (each bullet damages at most one enemy).
  const spentBullets = new Set<Bullet>()
  for (const bullet of world.bullets) {
    if (bullet.owner !== 'player') {
      continue
    }
    for (const enemy of world.enemies) {
      if (enemy.hp > 0 && aabbOverlap(bullet, enemy)) {
        enemy.hp -= 1
        spentBullets.add(bullet)
        if (enemy.hp <= 0) {
          world.score += scoreForKind(enemy.kind, config)
          world.particles.push(...createExplosion(enemy.x, enemy.y, config))
        }
        break
      }
    }
  }
  world.bullets = world.bullets.filter((b) => !spentBullets.has(b))
  world.enemies = world.enemies.filter((e) => e.hp > 0)

  // Player bullets vs the boss.
  if (world.boss) {
    const boss = world.boss
    const bossSpent = new Set<Bullet>()
    for (const b of world.bullets) {
      if (b.owner === 'player' && aabbOverlap(b, boss)) {
        boss.hp -= 1
        boss.hitFlash = 0.06
        bossSpent.add(b)
        const sparks = createExplosion(b.x, b.y, config).slice(0, config.boss.hitSpark)
        world.particles.push(...sparks)
      }
    }
    if (bossSpent.size > 0) {
      world.bullets = world.bullets.filter((b) => !bossSpent.has(b))
    }
    if (boss.hp <= 0) {
      world.particles.push(...createExplosion(boss.x, boss.y, config))
      world.particles.push(...createExplosion(boss.x - boss.width / 4, boss.y, config))
      world.particles.push(...createExplosion(boss.x + boss.width / 4, boss.y, config))
      world.score += config.boss.scoreBonus + (tierForLevel(world.level) - 1) * config.boss.bonusPerTier
      world.lives += 1
      world.boss = null
    }
  }

  // Enemy bullets and enemy bodies vs the player.
  if (world.player.invincibleFor === 0) {
    const hittingBullets = new Set<Bullet>(
      world.bullets.filter((b) => b.owner === 'enemy' && aabbOverlap(b, world.player)),
    )
    const rammingEnemies = world.enemies.filter((e) => aabbOverlap(e, world.player))
    if (hittingBullets.size > 0 || rammingEnemies.length > 0) {
      world.bullets = world.bullets.filter((b) => !hittingBullets.has(b))
      // Ramming an enemy destroys it too (mutual destruction).
      for (const enemy of rammingEnemies) {
        world.particles.push(...createExplosion(enemy.x, enemy.y, config))
      }
      const ramming = new Set(rammingEnemies)
      world.enemies = world.enemies.filter((e) => !ramming.has(e))
      loseLife(world, config)
    }
  }

  // Boss body vs the player (the boss is unharmed by ramming).
  if (
    world.phase === 'playing' &&
    world.boss &&
    world.player.invincibleFor === 0 &&
    aabbOverlap(world.boss, world.player)
  ) {
    loseLife(world, config)
  }

  // Particles.
  world.particles = updateParticles(world.particles, dt)

  // Level complete: everything spawned, no enemies, and no boss remaining.
  if (
    world.phase === 'playing' &&
    world.enemiesToSpawn === 0 &&
    world.enemies.length === 0 &&
    world.boss === null
  ) {
    startLevel(world, world.level + 1, config)
  }
}
registry/components/games/space-invaders/render.ts
import { CANVAS, GAME_CONSTANTS } from './config'
import type { Boss, Enemy, Player, World } from './types'

export interface ResolvedColors {
  background: string
  player: string
  playerBullet: string
  enemyBullet: string
  enemy: string
  particle: string
  boss: string
  bossHit: string
  exhaust: readonly [string, string, string]
}

type SpriteGrid = readonly (readonly number[])[]

const shipSprite: SpriteGrid = [
  [0, 0, 1, 0, 0],
  [0, 1, 1, 1, 0],
  [1, 1, 1, 1, 1],
  [1, 1, 1, 1, 1],
  [1, 0, 1, 0, 1],
]

// Angular fighter with three turret prongs, diving point-down at the player.
const gruntSprite: SpriteGrid = [
  [1, 0, 1, 0, 1],
  [1, 1, 1, 1, 1],
  [1, 1, 1, 1, 1],
  [0, 1, 1, 1, 0],
  [0, 0, 1, 0, 0],
]

// Winged interceptor with a central spike. Symmetric, point-down.
const weaverSprite: SpriteGrid = [
  [1, 0, 0, 0, 0, 0, 1],
  [1, 1, 0, 1, 0, 1, 1],
  [1, 1, 1, 1, 1, 1, 1],
  [0, 0, 1, 1, 1, 0, 0],
  [0, 0, 0, 1, 0, 0, 0],
]

// Bulky battlecruiser with twin cannon gaps. Symmetric.
const tankSprite: SpriteGrid = [
  [0, 1, 1, 1, 1, 1, 1, 0],
  [1, 1, 1, 1, 1, 1, 1, 1],
  [1, 1, 0, 1, 1, 0, 1, 1],
  [1, 1, 1, 1, 1, 1, 1, 1],
  [0, 1, 1, 1, 1, 1, 1, 0],
  [0, 0, 1, 1, 1, 1, 0, 0],
]

const bossSprite: SpriteGrid = [
  [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
  [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
  [1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1],
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
  [1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1],
  [0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0],
  [0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0],
]

function drawSprite(
  ctx: CanvasRenderingContext2D,
  grid: SpriteGrid,
  cx: number,
  cy: number,
  w: number,
  h: number,
  color: string,
): void {
  const rows = grid.length
  const cols = grid[0].length
  const cw = w / cols
  const ch = h / rows
  const gapX = cw * GAME_CONSTANTS.cellGap
  const gapY = ch * GAME_CONSTANTS.cellGap
  const left = cx - w / 2
  const top = cy - h / 2
  ctx.fillStyle = color
  for (let r = 0; r < rows; r += 1) {
    for (let c = 0; c < cols; c += 1) {
      if (grid[r][c]) {
        ctx.fillRect(
          Math.round(left + c * cw + gapX / 2),
          Math.round(top + r * ch + gapY / 2),
          Math.max(1, Math.round(cw - gapX)),
          Math.max(1, Math.round(ch - gapY)),
        )
      }
    }
  }
}

function enemyVisual(enemy: Enemy): SpriteGrid {
  switch (enemy.kind) {
    case 'weaver':
      return weaverSprite
    case 'tank':
      return tankSprite
    default:
      return gruntSprite
  }
}

/** Flickering engine flame emitted from the rear (bottom) of the ship. */
function drawExhaust(
  ctx: CanvasRenderingContext2D,
  player: Player,
  frame: number,
  colors: ResolvedColors,
): void {
  const cell = player.width / GAME_CONSTANTS.exhaustCells
  const startY = player.y + player.height / 2 - cell * 0.4
  const phase = Math.floor(frame / 3) % 2 // toggles flame shape every 3 frames
  const tongues = [
    { col: -1, segs: phase === 0 ? 2 : 1 },
    { col: 0, segs: phase === 0 ? 3 : 4 },
    { col: 1, segs: phase === 0 ? 2 : 1 },
  ]
  for (const tongue of tongues) {
    for (let s = 0; s < tongue.segs; s += 1) {
      const size = cell * (1 - s * 0.18)
      ctx.fillStyle = colors.exhaust[Math.min(s, colors.exhaust.length - 1)]
      ctx.fillRect(
        Math.round(player.x + tongue.col * cell - size / 2),
        Math.round(startY + s * cell * 0.85),
        Math.max(2, Math.round(size)),
        Math.max(2, Math.round(size * 0.9)),
      )
    }
  }
}

function drawMuzzleFlash(
  ctx: CanvasRenderingContext2D,
  player: Player,
  colors: ResolvedColors,
): void {
  const cell = player.width / GAME_CONSTANTS.exhaustCells
  const noseY = player.y - player.height / 2 - cell * 0.4
  ctx.fillStyle = colors.playerBullet
  // A small 3-cell cross burst at the nose.
  ctx.fillRect(
    Math.round(player.x - cell / 2),
    Math.round(noseY - cell),
    Math.max(2, Math.round(cell)),
    Math.max(2, Math.round(cell)),
  )
  ctx.fillRect(
    Math.round(player.x - cell),
    Math.round(noseY),
    Math.max(2, Math.round(cell * 0.6)),
    Math.max(2, Math.round(cell * 0.8)),
  )
  ctx.fillRect(
    Math.round(player.x + cell * 0.4),
    Math.round(noseY),
    Math.max(2, Math.round(cell * 0.6)),
    Math.max(2, Math.round(cell * 0.8)),
  )
}

const ROMAN = ['', '', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']

/** Boss name, with a tier numeral for the tougher bosses at level 20, 30, ... */
function bossName(level: number): string {
  const tier = Math.floor(level / GAME_CONSTANTS.bossEveryLevels)
  if (tier <= 1) {
    return 'REBEL CRUSHER'
  }
  return `REBEL CRUSHER ${ROMAN[tier] ?? tier}`
}

function drawBoss(
  ctx: CanvasRenderingContext2D,
  boss: Boss,
  name: string,
  colors: ResolvedColors,
): void {
  const color = boss.hitFlash > 0 ? colors.bossHit : colors.boss
  drawSprite(ctx, bossSprite, boss.x, boss.y, boss.width, boss.height, color)

  // Health bar, placed clearly below the HUD row so the two never overlap.
  const margin = 24
  const barWidth = CANVAS.width - margin * 2
  const barHeight = 14
  const barY = 96

  // Boss name — large and bold so it reads clearly.
  ctx.fillStyle = colors.bossHit
  ctx.font = "700 20px ui-monospace, monospace"
  ctx.textBaseline = 'alphabetic'
  ctx.fillText(name, margin, barY - 10)

  ctx.fillStyle = colors.enemy
  ctx.globalAlpha = 0.25
  ctx.fillRect(margin, barY, barWidth, barHeight)
  ctx.globalAlpha = 1
  const ratio = Math.max(0, boss.hp) / boss.maxHp
  ctx.fillStyle = colors.boss
  ctx.fillRect(margin, barY, Math.round(barWidth * ratio), barHeight)
}

export function renderWorld(
  ctx: CanvasRenderingContext2D,
  world: World,
  colors: ResolvedColors,
): void {
  ctx.fillStyle = colors.background
  ctx.fillRect(0, 0, CANVAS.width, CANVAS.height)

  for (const b of world.bullets) {
    ctx.fillStyle = b.owner === 'player' ? colors.playerBullet : colors.enemyBullet
    ctx.fillRect(b.x - b.width / 2, b.y - b.height / 2, b.width, b.height)
  }

  for (const enemy of world.enemies) {
    drawSprite(ctx, enemyVisual(enemy), enemy.x, enemy.y, enemy.width, enemy.height, colors.enemy)
  }

  if (world.boss) {
    drawBoss(ctx, world.boss, bossName(world.level), colors)
  }

  // Explosion debris: themed blocks that fade as they age.
  for (const p of world.particles) {
    ctx.globalAlpha = Math.max(0, p.life / p.maxLife) * (0.4 + 0.6 * p.shade)
    ctx.fillStyle = colors.particle
    ctx.fillRect(Math.round(p.x), Math.round(p.y), p.size, p.size)
  }
  ctx.globalAlpha = 1

  // Blink the player while invincible.
  const blinkHidden =
    world.player.invincibleFor > 0 && Math.floor(world.player.invincibleFor * 10) % 2 === 0
  if (!blinkHidden) {
    if (world.phase === 'playing') {
      drawExhaust(ctx, world.player, world.frame, colors)
    }
    if (world.player.muzzleFlash > 0) {
      drawMuzzleFlash(ctx, world.player, colors)
    }
    drawSprite(
      ctx,
      shipSprite,
      world.player.x,
      world.player.y,
      world.player.width,
      world.player.height,
      colors.player,
    )
  }
}
registry/components/games/space-invaders/types.ts
// ---- Geometry & entities -------------------------------------------------

export interface Entity {
  x: number // center x
  y: number // center y
  width: number
  height: number
}

export type EnemyKind = 'grunt' | 'weaver' | 'tank'

export interface Enemy extends Entity {
  kind: EnemyKind
  hp: number
  speedY: number
  age: number
  spawnX: number
  fireCooldown: number
}

export type BulletOwner = 'player' | 'enemy'

export interface Bullet extends Entity {
  vx: number
  vy: number
  owner: BulletOwner
}

export interface Particle {
  x: number
  y: number
  vx: number
  vy: number
  life: number
  maxLife: number
  size: number
  shade: number
}

export interface Player extends Entity {
  fireCooldown: number
  invincibleFor: number
  muzzleFlash: number
}

export type BossPattern = 'aimed' | 'radial'

export interface Boss extends Entity {
  hp: number
  maxHp: number
  dirX: number
  age: number
  fireCooldown: number
  fireInterval: number
  patternTimer: number
  pattern: BossPattern
  hitFlash: number
}

export type GameState = 'idle' | 'playing' | 'paused' | 'gameOver'

export interface World {
  phase: GameState
  player: Player
  bullets: Bullet[]
  enemies: Enemy[]
  boss: Boss | null
  particles: Particle[]
  score: number
  lives: number
  level: number
  highScore: number
  spawnTimer: number
  enemiesToSpawn: number
  frame: number
}

/** HUD-relevant snapshot, pushed to React only when a value changes. */
export interface HudState {
  score: number
  lives: number
  level: number
  highScore: number
  phase: GameState
}

// ---- Input ---------------------------------------------------------------

export interface InputState {
  up: boolean
  down: boolean
  left: boolean
  right: boolean
  fire: boolean
}

// ---- Configuration -------------------------------------------------------

/** Theme-aware colors (CSS variables or any valid CSS color). */
export interface SpaceInvadersColors {
  background: string
  player: string
  playerBullet: string
  enemyBullet: string
  enemy: string
  particle: string
  boss: string
  bossHit: string
  /** Engine flame gradient, core → tail. */
  exhaust: readonly [string, string, string]
}

export interface SpaceInvadersConfig {
  colors: SpaceInvadersColors
  player: {
    width: number
    height: number
    speed: number
    fireInterval: number
    bulletSpeed: number
    invincibleTime: number
    startLives: number
    startX: number
    startY: number
    muzzleFlashTime: number
  }
  enemy: {
    bulletSpeed: number
    fireIntervalBase: number
    fireIntervalRamp: number
    fireIntervalMin: number
    grunt: { width: number; height: number; hp: number; speedY: number; score: number }
    weaver: {
      width: number
      height: number
      hp: number
      speedY: number
      score: number
      amplitude: number
      frequency: number
    }
    tank: { width: number; height: number; hp: number; speedY: number; score: number }
  }
  bullet: { width: number; height: number }
  spawn: {
    baseEnemies: number
    enemiesPerLevel: number
    baseInterval: number
    rampPerLevel: number
    minInterval: number
  }
  particle: { countPerExplosion: number; speed: number; life: number; sizeMin: number; sizeMax: number }
  boss: {
    width: number
    height: number
    marginY: number
    speedX: number
    baseHp: number
    hpPerTier: number
    bulletSpeed: number
    fireIntervalBase: number
    fireIntervalRamp: number
    fireIntervalMin: number
    patternSwitch: number
    aimedCount: number
    aimedSpread: number
    aimJitter: number
    radialCount: number
    scoreBonus: number
    bonusPerTier: number
    hitSpark: number
  }
}

export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

export interface SpaceInvadersProps {
  config?: DeepPartial<SpaceInvadersConfig>
  showControls?: boolean
  onScoreChange?: (score: number) => void
  onStateChange?: (state: GameState) => void
  onGameEnd?: (result: { score: number; level: number }) => void
  className?: string
}
registry/components/games/space-invaders/config.ts
import type { SpaceInvadersColors, SpaceInvadersConfig } from './types'

/** Logical canvas resolution (scaled responsively by CSS). 3:4 portrait. */
export const CANVAS = { width: 720, height: 960 } as const

/** Theme-aware default colors using shadcn CSS variables. */
export const DEFAULT_COLORS: SpaceInvadersColors = {
  background: 'var(--muted)',
  player: 'var(--foreground)',
  playerBullet: 'var(--primary)',
  enemyBullet: 'var(--destructive)',
  enemy: 'var(--muted-foreground)',
  particle: 'var(--muted-foreground)',
  boss: 'var(--destructive)',
  bossHit: 'var(--foreground)',
  exhaust: ['#fde047', '#fb923c', '#ef4444'],
}

export const DEFAULT_CONFIG: SpaceInvadersConfig = {
  colors: DEFAULT_COLORS,
  player: {
    width: 36,
    height: 36,
    speed: 540,
    fireInterval: 0.14,
    bulletSpeed: 680,
    invincibleTime: 1.6,
    startLives: 3,
    startX: 360,
    startY: 820,
    muzzleFlashTime: 0.06,
  },
  enemy: {
    bulletSpeed: 280,
    fireIntervalBase: 1.8,
    fireIntervalRamp: 0.12,
    fireIntervalMin: 0.6,
    grunt: { width: 42, height: 42, hp: 1, speedY: 110, score: 50 },
    weaver: { width: 50, height: 38, hp: 1, speedY: 90, score: 80, amplitude: 140, frequency: 1.6 },
    tank: { width: 70, height: 54, hp: 4, speedY: 60, score: 150 },
  },
  bullet: { width: 6, height: 18 },
  spawn: {
    baseEnemies: 8,
    enemiesPerLevel: 4,
    baseInterval: 1.1,
    rampPerLevel: 0.1,
    minInterval: 0.35,
  },
  particle: { countPerExplosion: 18, speed: 210, life: 0.6, sizeMin: 3, sizeMax: 8 },
  boss: {
    width: 180,
    height: 120,
    marginY: 135,
    speedX: 110,
    baseHp: 50,
    hpPerTier: 40,
    bulletSpeed: 240,
    fireIntervalBase: 0.55,
    fireIntervalRamp: 0.08,
    fireIntervalMin: 0.25,
    patternSwitch: 3.5,
    aimedCount: 3,
    aimedSpread: 0.35,
    aimJitter: 0.4,
    radialCount: 16,
    scoreBonus: 1000,
    bonusPerTier: 500,
    hitSpark: 4,
  },
}

/** Game constants (not configurable). */
export const GAME_CONSTANTS = {
  bossEveryLevels: 10,
  fixedStep: 1 / 60,
  maxStepsPerFrame: 5,
  cellGap: 0.18,
  exhaustCells: 5,
  /** 3:4 portrait. */
  aspectRatio: CANVAS.width / CANVAS.height,
  storageKey: 'space-invaders-highscore',
} as const

/** Keyboard bindings (event.code). */
export const KEY_BINDINGS = {
  up: ['ArrowUp', 'KeyW'],
  down: ['ArrowDown', 'KeyS'],
  left: ['ArrowLeft', 'KeyA'],
  right: ['ArrowRight', 'KeyD'],
  fire: ['Space'],
  pause: ['KeyP', 'Escape'],
  skip: ['KeyN'],
} as const
registry/components/games/space-invaders/utils.ts
import type { DeepPartial, SpaceInvadersConfig } from './types'
import { GAME_CONSTANTS } from './config'

/** Deep-merge config overrides onto a base config (one level per section). */
export function mergeConfig(
  base: SpaceInvadersConfig,
  overrides?: DeepPartial<SpaceInvadersConfig>
): SpaceInvadersConfig {
  if (!overrides) return base
  const exhaustOverride = overrides.colors?.exhaust
  return {
    colors: {
      ...base.colors,
      ...overrides.colors,
      exhaust: [
        exhaustOverride?.[0] ?? base.colors.exhaust[0],
        exhaustOverride?.[1] ?? base.colors.exhaust[1],
        exhaustOverride?.[2] ?? base.colors.exhaust[2],
      ],
    },
    player: { ...base.player, ...overrides.player },
    enemy: {
      ...base.enemy,
      ...overrides.enemy,
      grunt: { ...base.enemy.grunt, ...overrides.enemy?.grunt },
      weaver: { ...base.enemy.weaver, ...overrides.enemy?.weaver },
      tank: { ...base.enemy.tank, ...overrides.enemy?.tank },
    },
    bullet: { ...base.bullet, ...overrides.bullet },
    spawn: { ...base.spawn, ...overrides.spawn },
    particle: { ...base.particle, ...overrides.particle },
    boss: { ...base.boss, ...overrides.boss },
  }
}

/** Resolve a `var(--x)` color to its computed value for canvas painting. */
export function resolveCssColor(color: string, element: HTMLElement): string {
  if (!color.includes('var(')) return color
  const computed = getComputedStyle(element)
  const match = color.match(/var\(\s*(--[\w-]+)/)
  if (!match) return color
  const resolved = computed.getPropertyValue(match[1]).trim()
  return resolved || color
}

/** Zero-pad a score to 7 digits. */
export function formatScore(score: number): string {
  return score.toString().padStart(7, '0')
}

/** Single high-score persistence (SSR-safe, swallows parse/quota errors). */
export const storage = {
  getHighScore(key: string = GAME_CONSTANTS.storageKey): number {
    if (typeof window === 'undefined') return 0
    try {
      const raw = localStorage.getItem(key)
      if (raw === null) return 0
      const parsed = Number.parseInt(raw, 10)
      return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
    } catch {
      return 0
    }
  },
  setHighScore(score: number, key: string = GAME_CONSTANTS.storageKey): void {
    if (typeof window === 'undefined') return
    try {
      localStorage.setItem(key, String(Math.floor(score)))
    } catch {
      // Storage unavailable — high score is non-critical.
    }
  },
}

More from joyco

Browse all components in this library →