From de30bec47923565fe5d1d5a6f719f3fc4e9d1509 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 15 Oct 2025 18:09:09 -0500 Subject: [PATCH] feat(arcade): add modular game SDK and registry system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create foundation for modular arcade game architecture: **Game SDK** (`/src/lib/arcade/game-sdk/`): - Stable API surface that games can safely import - Type-safe game definition with `defineGame()` helper - Controlled hook exports (useArcadeSession, useRoomData, etc.) - Player ownership and metadata utilities - Error boundary component for game crashes **Manifest System**: - YAML-based game manifests with Zod validation - Game metadata (name, icon, description, difficulty, etc.) - Type-safe manifest loading with `loadManifest()` **Game Registry**: - Central registry for all arcade games - Explicit registration pattern via `registerGame()` - Helper functions to query available games **Type Safety**: - Full TypeScript contracts for games - GameValidator, GameState, GameMove, GameConfig types - Compile-time validation of game implementations This establishes the plugin system for drop-in arcade games. Next: Create demo games to exercise the system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/package.json | 2 + apps/web/src/lib/arcade/game-registry.ts | 76 +++++++++++ .../lib/arcade/game-sdk/GameErrorBoundary.tsx | 124 ++++++++++++++++++ .../src/lib/arcade/game-sdk/define-game.ts | 80 +++++++++++ apps/web/src/lib/arcade/game-sdk/index.ts | 92 +++++++++++++ .../src/lib/arcade/game-sdk/load-manifest.ts | 39 ++++++ apps/web/src/lib/arcade/game-sdk/types.ts | 80 +++++++++++ apps/web/src/lib/arcade/manifest-schema.ts | 38 ++++++ pnpm-lock.yaml | 11 ++ 9 files changed, 542 insertions(+) create mode 100644 apps/web/src/lib/arcade/game-registry.ts create mode 100644 apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx create mode 100644 apps/web/src/lib/arcade/game-sdk/define-game.ts create mode 100644 apps/web/src/lib/arcade/game-sdk/index.ts create mode 100644 apps/web/src/lib/arcade/game-sdk/load-manifest.ts create mode 100644 apps/web/src/lib/arcade/game-sdk/types.ts create mode 100644 apps/web/src/lib/arcade/manifest-schema.ts diff --git a/apps/web/package.json b/apps/web/package.json index fa0a044b..58c29b6c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,6 +59,7 @@ "drizzle-orm": "^0.44.6", "emojibase-data": "^16.0.3", "jose": "^6.1.0", + "js-yaml": "^4.1.0", "lucide-react": "^0.294.0", "make-plural": "^7.4.0", "nanoid": "^5.1.6", @@ -80,6 +81,7 @@ "@testing-library/react": "^16.3.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", diff --git a/apps/web/src/lib/arcade/game-registry.ts b/apps/web/src/lib/arcade/game-registry.ts new file mode 100644 index 00000000..1bd6d5f7 --- /dev/null +++ b/apps/web/src/lib/arcade/game-registry.ts @@ -0,0 +1,76 @@ +/** + * Game Registry + * + * Central registry for all arcade games. + * Games are explicitly registered here after being defined. + */ + +import type { GameDefinition } from './game-sdk/types' + +/** + * Global game registry + * Maps game name to game definition + */ +const registry = new Map() + +/** + * Register a game in the registry + * + * @param game - Game definition to register + * @throws Error if game with same name already registered + */ +export function registerGame(game: GameDefinition): void { + const { name } = game.manifest + + if (registry.has(name)) { + throw new Error(`Game "${name}" is already registered`) + } + + registry.set(name, game) + console.log(`✅ Registered game: ${name}`) +} + +/** + * Get a game from the registry + * + * @param gameName - Internal game identifier + * @returns Game definition or undefined if not found + */ +export function getGame(gameName: string): GameDefinition | undefined { + return registry.get(gameName) +} + +/** + * Get all registered games + * + * @returns Array of all game definitions + */ +export function getAllGames(): GameDefinition[] { + return Array.from(registry.values()) +} + +/** + * Get all available games (where available: true) + * + * @returns Array of available game definitions + */ +export function getAvailableGames(): GameDefinition[] { + return getAllGames().filter((game) => game.manifest.available) +} + +/** + * Check if a game is registered + * + * @param gameName - Internal game identifier + * @returns true if game is registered + */ +export function hasGame(gameName: string): boolean { + return registry.has(gameName) +} + +/** + * Clear all games from registry (used for testing) + */ +export function clearRegistry(): void { + registry.clear() +} diff --git a/apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx b/apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx new file mode 100644 index 00000000..ee82af52 --- /dev/null +++ b/apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx @@ -0,0 +1,124 @@ +/** + * Error Boundary for Arcade Games + * + * Catches errors in game components and displays a friendly error message + * instead of crashing the entire app. + */ + +'use client' + +import { Component, type ReactNode } from 'react' + +interface Props { + children: ReactNode + gameName?: string +} + +interface State { + hasError: boolean + error?: Error +} + +export class GameErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error): State { + return { + hasError: true, + error, + } + } + + componentDidCatch(error: Error, errorInfo: unknown) { + console.error('Game error:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + return ( +
+
+ ⚠️ +
+

+ Game Error +

+

+ {this.props.gameName + ? `There was an error loading the game "${this.props.gameName}".` + : 'There was an error loading the game.'} +

+ {this.state.error && ( +
+              {this.state.error.message}
+            
+ )} + +
+ ) + } + + return this.props.children + } +} diff --git a/apps/web/src/lib/arcade/game-sdk/define-game.ts b/apps/web/src/lib/arcade/game-sdk/define-game.ts new file mode 100644 index 00000000..d73102a5 --- /dev/null +++ b/apps/web/src/lib/arcade/game-sdk/define-game.ts @@ -0,0 +1,80 @@ +/** + * Game definition helper + * Provides type-safe game registration + */ + +import type { + GameComponent, + GameConfig, + GameDefinition, + GameMove, + GameProviderComponent, + GameState, + GameValidator, +} from './types' +import type { GameManifest } from '../manifest-schema' + +/** + * Options for defining a game + */ +export interface DefineGameOptions< + TConfig extends GameConfig, + TState extends GameState, + TMove extends GameMove, +> { + /** Game manifest (loaded from game.yaml) */ + manifest: GameManifest + + /** React provider component */ + Provider: GameProviderComponent + + /** Main game UI component */ + GameComponent: GameComponent + + /** Server-side validator */ + validator: GameValidator + + /** Default configuration for the game */ + defaultConfig: TConfig +} + +/** + * Define a game with full type safety + * + * This helper ensures all required parts of a game are provided + * and returns a properly typed GameDefinition. + * + * @example + * ```typescript + * export const myGame = defineGame({ + * manifest: loadManifest('./game.yaml'), + * Provider: MyGameProvider, + * GameComponent: MyGameComponent, + * validator: myGameValidator, + * defaultConfig: { + * difficulty: 'easy', + * maxTime: 60 + * } + * }) + * ``` + */ +export function defineGame< + TConfig extends GameConfig, + TState extends GameState, + TMove extends GameMove, +>(options: DefineGameOptions): GameDefinition { + const { manifest, Provider, GameComponent, validator, defaultConfig } = options + + // Validate that manifest.name matches the game identifier + if (!manifest.name) { + throw new Error('Game manifest must have a "name" field') + } + + return { + manifest, + Provider, + GameComponent, + validator, + defaultConfig, + } +} diff --git a/apps/web/src/lib/arcade/game-sdk/index.ts b/apps/web/src/lib/arcade/game-sdk/index.ts new file mode 100644 index 00000000..fd3dc4d1 --- /dev/null +++ b/apps/web/src/lib/arcade/game-sdk/index.ts @@ -0,0 +1,92 @@ +/** + * Arcade Game SDK - Stable API Surface + * + * This is the ONLY module that games are allowed to import from. + * All game code must use this SDK - no direct imports from /src/ + * + * @example + * ```typescript + * import { + * defineGame, + * useArcadeSession, + * useRoomData, + * type GameDefinition + * } from '@/lib/arcade/game-sdk' + * ``` + */ + +// ============================================================================ +// Core Types +// ============================================================================ +export type { + GameDefinition, + GameProviderComponent, + GameComponent, + GameValidator, + GameConfig, + GameState, + GameMove, + ValidationContext, + ValidationResult, + TeamMoveSentinel, +} from './types' + +export { TEAM_MOVE } from './types' + +export type { GameManifest } from '../manifest-schema' + +// ============================================================================ +// React Hooks (Controlled API) +// ============================================================================ + +/** + * Arcade session management hook + * Handles state synchronization, move validation, and multiplayer sync + */ +export { useArcadeSession } from '@/hooks/useArcadeSession' + +/** + * Room data hook - access current room information + */ +export { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData' + +/** + * Game mode context - access players and game mode + */ +export { useGameMode } from '@/contexts/GameModeContext' + +/** + * Viewer ID hook - get current user's ID + */ +export { useViewerId } from '@/hooks/useViewerId' + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Player ownership and metadata utilities + */ +export { + buildPlayerMetadata, + buildPlayerOwnershipFromRoomData, +} from '@/lib/arcade/player-ownership.client' + +/** + * Helper for loading and validating game manifests + */ +export { loadManifest } from './load-manifest' + +/** + * Game definition helper + */ +export { defineGame } from './define-game' + +// ============================================================================ +// Re-exports for convenience +// ============================================================================ + +/** + * Common types from contexts + */ +export type { Player } from '@/contexts/GameModeContext' diff --git a/apps/web/src/lib/arcade/game-sdk/load-manifest.ts b/apps/web/src/lib/arcade/game-sdk/load-manifest.ts new file mode 100644 index 00000000..c59ac7a8 --- /dev/null +++ b/apps/web/src/lib/arcade/game-sdk/load-manifest.ts @@ -0,0 +1,39 @@ +/** + * Manifest loading and validation utilities + */ + +import yaml from 'js-yaml' +import { readFileSync } from 'fs' +import { join } from 'path' +import { validateManifest, type GameManifest } from '../manifest-schema' + +/** + * Load and validate a game manifest from a YAML file + * + * @param manifestPath - Absolute path to game.yaml file + * @returns Validated GameManifest object + * @throws Error if manifest is invalid or file doesn't exist + */ +export function loadManifest(manifestPath: string): GameManifest { + try { + const fileContents = readFileSync(manifestPath, 'utf8') + const data = yaml.load(fileContents) + return validateManifest(data) + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to load manifest from ${manifestPath}: ${error.message}`) + } + throw error + } +} + +/** + * Load manifest from a game directory + * + * @param gameDir - Absolute path to game directory + * @returns Validated GameManifest object + */ +export function loadManifestFromDir(gameDir: string): GameManifest { + const manifestPath = join(gameDir, 'game.yaml') + return loadManifest(manifestPath) +} diff --git a/apps/web/src/lib/arcade/game-sdk/types.ts b/apps/web/src/lib/arcade/game-sdk/types.ts new file mode 100644 index 00000000..835fcd3b --- /dev/null +++ b/apps/web/src/lib/arcade/game-sdk/types.ts @@ -0,0 +1,80 @@ +/** + * Type definitions for the Arcade Game SDK + * These types define the contract that all games must implement + */ + +import type { ReactNode } from 'react' +import type { GameManifest } from '../manifest-schema' +import type { + GameMove as BaseGameMove, + GameValidator as BaseGameValidator, + ValidationContext, + ValidationResult, +} from '../validation/types' + +/** + * Re-export base validation types from arcade system + */ +export type { GameMove, ValidationContext, ValidationResult } from '../validation/types' +export { TEAM_MOVE } from '../validation/types' +export type { TeamMoveSentinel } from '../validation/types' + +/** + * Generic game configuration + * Each game defines its own specific config type + */ +export type GameConfig = Record + +/** + * Generic game state + * Each game defines its own specific state type + */ +export type GameState = Record + +/** + * Game validator interface + * Games must implement this to validate moves server-side + */ +export interface GameValidator + extends BaseGameValidator { + validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult + isGameComplete(state: TState): boolean + getInitialState(config: unknown): TState +} + +/** + * Provider component interface + * Each game provides a React context provider that wraps the game UI + */ +export type GameProviderComponent = (props: { children: ReactNode }) => JSX.Element + +/** + * Main game component interface + * The root component that renders the game UI + */ +export type GameComponent = () => JSX.Element + +/** + * Complete game definition + * This is what games export after using defineGame() + */ +export interface GameDefinition< + TConfig extends GameConfig = GameConfig, + TState extends GameState = GameState, + TMove extends BaseGameMove = BaseGameMove, +> { + /** Parsed and validated manifest */ + manifest: GameManifest + + /** React provider component */ + Provider: GameProviderComponent + + /** Main game UI component */ + GameComponent: GameComponent + + /** Server-side validator */ + validator: GameValidator + + /** Default configuration */ + defaultConfig: TConfig +} diff --git a/apps/web/src/lib/arcade/manifest-schema.ts b/apps/web/src/lib/arcade/manifest-schema.ts new file mode 100644 index 00000000..a3594c3a --- /dev/null +++ b/apps/web/src/lib/arcade/manifest-schema.ts @@ -0,0 +1,38 @@ +/** + * Game manifest schema validation + * Validates game.yaml files using Zod + */ + +import { z } from 'zod' + +/** + * Schema for game manifest (game.yaml) + */ +export const GameManifestSchema = z.object({ + name: z.string().min(1).describe('Internal game identifier (e.g., "matching")'), + displayName: z.string().min(1).describe('Display name shown to users'), + icon: z.string().min(1).describe('Emoji icon for the game'), + description: z.string().min(1).describe('Short description'), + longDescription: z.string().min(1).describe('Detailed description'), + maxPlayers: z.number().int().min(1).max(10).describe('Maximum number of players'), + difficulty: z + .enum(['Beginner', 'Intermediate', 'Advanced', 'Expert']) + .describe('Difficulty level'), + chips: z.array(z.string()).describe('Feature chips displayed on game card'), + color: z.string().min(1).describe('Color theme (e.g., "purple")'), + gradient: z.string().min(1).describe('CSS gradient for card background'), + borderColor: z.string().min(1).describe('Border color (e.g., "purple.200")'), + available: z.boolean().describe('Whether game is available to play'), +}) + +/** + * Inferred TypeScript type from schema + */ +export type GameManifest = z.infer + +/** + * Validate a parsed manifest object + */ +export function validateManifest(data: unknown): GameManifest { + return GameManifestSchema.parse(data) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c503c587..58496d48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: jose: specifier: ^6.1.0 version: 6.1.0 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 lucide-react: specifier: ^0.294.0 version: 0.294.0(react@18.3.1) @@ -210,6 +213,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^20.0.0 version: 20.19.19 @@ -3668,6 +3674,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -13150,6 +13159,8 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/js-yaml@4.0.9': {} + '@types/jsdom@21.1.7': dependencies: '@types/node': 20.19.19