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