# Web Games Platform Design **Date:** 2026-04-02 **Status:** Draft ## Overview Extend the Aurora web panel beyond admin-only use. Enrolled bot players get a player dashboard (stats, inventory, activity) and access to a multiplayer game system. Admins keep the existing admin panel unchanged. Games are built as plugins — adding a new game requires implementing a defined interface without touching server or framework code. ## Requirements - Discord OAuth open to all enrolled bot users (exist in `users` table), not just admins - Two-tier experience: admins see admin panel under `/admin/*`, players see player dashboard + games - Explicit room creation with browsing — no matchmaking - Session-only (in-memory) game state — no database persistence for games - Open spectating on all games - Shareable room URLs: `/:gameSlug/:roomId` - Plugin architecture so adding a game is self-contained ## Auth & Role System ### Changes to OAuth Flow The current OAuth callback in `auth.routes.ts` checks `ADMIN_USER_IDS` and returns 403 for non-admins. This changes to: 1. Any Discord user can complete OAuth (remove the 403 gate) 2. After getting Discord user info, look up their Discord ID in the `users` table 3. **Not enrolled** (no row): do not create a session. Return `{ authenticated: false, enrolled: false }` 4. **Enrolled**: create a session with a `role` field — `"admin"` if their ID is in `ADMIN_USER_IDS`, otherwise `"player"` ### Session Shape ```typescript // Before { discordId, username, avatar, expiresAt } // After { discordId, username, avatar, role: "admin" | "player", expiresAt } ``` ### `/auth/me` Response ```typescript { authenticated: boolean, enrolled: boolean, user?: { discordId, username, avatar, role } } ``` ### Panel Behavior - Not authenticated → redirect to Discord OAuth - Authenticated but not enrolled → "You need to use the bot first" page - Authenticated + enrolled as player → player experience - Authenticated + enrolled as admin → admin experience ## Routing ### Adopting React Router The current state-based page switching (`setPage()`) cannot handle URL paths. Adopt React Router for: - URL-based navigation with params (`:roomId`, `:gameSlug`) - Shareable game room links - Clean separation of admin vs player route trees ### URL Structure | Path | Role | Description | |---|---|---| | `/` | any | Redirect: admins → `/admin`, players → `/dashboard` | | `/dashboard` | player | Player dashboard (stats, inventory, activity) | | `/games` | player | Game lobby — browse/create rooms | | `/:gameSlug/:roomId` | any | Game room (play or spectate) | | `/admin` | admin | Admin dashboard (existing) | | `/admin/users` | admin | User management (existing) | | `/admin/items` | admin | Item management (existing) | | `/admin/settings` | admin | Settings (existing) | | `/admin/classes` | admin | Class management (existing) | | `/admin/quests` | admin | Quest management (existing) | | `/admin/lootdrops` | admin | Lootdrop management (existing) | | `/admin/moderation` | admin | Moderation cases (existing) | | `/admin/transactions` | admin | Transaction log (existing) | Game room URLs (`/:gameSlug/:roomId`) are accessible to any authenticated+enrolled user — both players and admins can play. ### Layout - Shared shell: header bar, sidebar container, user section - **Player sidebar:** Dashboard, Games, Leaderboards - **Admin sidebar:** existing nav items, plus a Games link so admins can play too - Sidebar renders different nav items based on `role` from `useAuth` ## WebSocket Architecture ### Channel Design Extend the existing `/ws` endpoint (Bun native WebSocket in `server.ts`): | Channel | Purpose | |---|---| | `dashboard` | Existing admin stats broadcast (unchanged) | | `lobby` | Room list updates — room created, closed, player count changes | | `room:` | Per-room game events | ### Server → Client Messages | Type | Channel | Payload | |---|---|---| | `STATS_UPDATE` | `dashboard` | Existing dashboard stats | | `ROOM_LIST_UPDATE` | `lobby` | `{ rooms: RoomSummary[] }` | | `GAME_STATE` | `room:` | Full view-filtered state snapshot | | `GAME_UPDATE` | `room:` | New view-filtered state after action | | `PLAYER_JOINED` | `room:` | `{ player: PlayerInfo, as: "player" \| "spectator" }` | | `PLAYER_LEFT` | `room:` | `{ playerId: string }` | | `GAME_STARTED` | `room:` | Initial game state | | `GAME_ENDED` | `room:` | `{ winner: string \| null, reason: string }` | | `ERROR` | direct to sender | `{ message: string }` | ### Client → Server Messages | Type | Payload | |---|---| | `JOIN_ROOM` | `{ roomId, as: "player" \| "spectator" }` | | `LEAVE_ROOM` | `{ roomId }` | | `GAME_ACTION` | `{ roomId, action: { type, ...data } }` | | `CREATE_ROOM` | `{ gameType }` | | `PING` | (existing heartbeat) | ### Connection Model - One WebSocket connection per client (shared across dashboard, lobby, and game rooms) - Auth: validate session cookie on WS upgrade, attach `discordId` and `role` to socket - A player can only be *playing* in one room at a time - Max connections limit raised from 10 to 200 ## Game Plugin Interface ### Server-Side (`shared/games/`) ```typescript interface GamePlugin { slug: string; // URL segment: "chess", "blackjack" name: string; // Display name: "Chess", "Blackjack" minPlayers: number; maxPlayers: number; createInitialState(players: string[]): TState; handleAction(state: TState, action: TAction, playerId: string): GameResult; getPlayerView(state: TState, playerId: string): Partial; getSpectatorView(state: TState): Partial; isGameOver?(state: TState): GameOverResult | null; onPlayerDisconnect?(state: TState, playerId: string): TState; } type GameResult = | { ok: true; state: TState } | { ok: false; error: string }; type GameOverResult = { winner: string | null; // playerId or null for draw reason: string; }; ``` Key properties: - `handleAction` is a **pure function** — state in, state out. No side effects, trivially testable. - `getPlayerView` handles information hiding — chess shows everything, blackjack hides opponent hands. - `getSpectatorView` provides a public-safe view. - Generic `TState`/`TAction` types give each game full type safety. ### Client-Side (`panel/src/games/`) ```typescript interface GameUIPlugin { slug: string; name: string; component: React.ComponentType; icon?: React.ComponentType; } interface GameUIProps { state: unknown; myPlayerId: string; isSpectator: boolean; onAction: (action: unknown) => void; players: PlayerInfo[]; } ``` Game components receive state and an action callback. No networking code — that's handled by the `useGameRoom` hook. ### Registry ```typescript // shared/games/registry.ts const games = new Map>(); export const gameRegistry = { register(plugin: GamePlugin) { games.set(plugin.slug, plugin); }, get(slug: string) { return games.get(slug); }, list() { return Array.from(games.values()); }, }; ``` Server uses `gameRegistry.get(slug)` to route actions. Lobby uses `gameRegistry.list()` to show available game types. Client has an equivalent `gameUIRegistry`. ## Room Management ### Room Data Structure ```typescript interface Room { id: string; // UUID gameSlug: string; host: string; // discordId of creator players: string[]; // discordIds of active players spectators: Set; state: unknown; // opaque game state status: "waiting" | "playing" | "finished"; createdAt: number; } ``` ### Lifecycle 1. **Create** — player sends `CREATE_ROOM { gameType }`. RoomManager generates UUID, creates room in `"waiting"` status, adds creator as first player. Broadcasts `ROOM_LIST_UPDATE` to lobby. Returns room ID — client navigates to `/:gameSlug/:roomId`. 2. **Join** — player sends `JOIN_ROOM { roomId, as: "player" }`. Validates room exists, not full, status is `"waiting"`. Adds to `players`. Broadcasts `PLAYER_JOINED`. If `players.length === maxPlayers`, auto-start. 3. **Start** — calls `plugin.createInitialState(players)`, sets status to `"playing"`. Sends each player their `getPlayerView`, spectators their `getSpectatorView`. 4. **Action** — player sends `GAME_ACTION { roomId, action }`. RoomManager calls `plugin.handleAction(state, action, playerId)`. If `ok`: update state, check `isGameOver()`, broadcast views. If not `ok`: send `ERROR` to that player only. 5. **End** — `isGameOver()` returns a result. Set status to `"finished"`, broadcast `GAME_ENDED`. Clean up room after 60 seconds. 6. **Disconnect** — if player's WebSocket closes, call `plugin.onPlayerDisconnect()` if defined. Remove from room after a short grace period. ### Cleanup - Rooms in `"waiting"` with no players for 60 seconds: deleted - Rooms in `"finished"`: deleted after 60 seconds - Server restart clears all rooms (session-only) ## Panel Pages ### Player Dashboard (`/dashboard`) - 4 stat cards: Level, Gold, Items, Games Won (gradient cards with left-border accents matching existing dashboard style) - Recent Activity list (card with timestamped entries) - Inventory preview (card with item list, equipped badges) - Data sourced from existing endpoints: `GET /api/users/:id`, `GET /api/users/:id/inventory` ### Game Lobby (`/games`) - Filter tabs by game type (generated from `gameUIRegistry.list()`) - Room list: each row shows game icon, room name, status badge (Waiting/Playing), player count, spectator count - "Waiting" rooms show Join button, "Playing" rooms show Spectate button - Create Room button opens a dialog: pick game type, room name - Live updates via `lobby` WebSocket channel ### Game Room (`/:gameSlug/:roomId`) - Room header: game icon, room name, status badge, spectator count, Leave/Forfeit buttons - Center: the game plugin's React component (rendered via `gameUIRegistry.get(slug).component`) - Side panel: Players list (with online indicators), Spectators list, game-specific info (e.g. move history for chess) - All updates via `room:` WebSocket channel - Room not found: friendly error page with link back to lobby ### Shared Hook: `useGameRoom` ```typescript function useGameRoom(roomId: string): { gameState: unknown; players: PlayerInfo[]; spectators: PlayerInfo[]; roomStatus: "waiting" | "playing" | "finished"; isSpectator: boolean; myPlayerId: string; sendAction: (action: unknown) => void; leaveRoom: () => void; error: string | null; } ``` On mount: sends `JOIN_ROOM`. Subscribes to room messages. On unmount: sends `LEAVE_ROOM`. The game component just reads `gameState` and calls `sendAction`. ### Shared Hook: `useWebSocket` Manages the single WebSocket connection for the entire panel: - Connects after auth - Reconnects with exponential backoff - Routes messages to subscribers by type/channel - Used by dashboard (existing stats), lobby (room list), and game rooms ## File Structure ``` shared/games/ types.ts — GamePlugin, GameResult, GameOverResult registry.ts — gameRegistry singleton chess/ plugin.ts — Chess GamePlugin implementation types.ts — ChessState, ChessAction plugin.test.ts blackjack/ plugin.ts — Blackjack GamePlugin implementation types.ts — BlackjackState, BlackjackAction plugin.test.ts api/src/games/ RoomManager.ts — Room CRUD, action routing, cleanup RoomManager.test.ts types.ts — Room, RoomEvent types ws-handler.ts — WS message parsing, auth, routing to RoomManager panel/src/ lib/ useWebSocket.ts — shared WS connection + message routing useGameRoom.ts — per-room hook games/ registry.ts — client-side GameUIPlugin registry GameRoom.tsx — generic room wrapper + renders plugin component GameLobby.tsx — room list + create dialog chess/ index.ts — registers ChessUI ChessBoard.tsx blackjack/ index.ts — registers BlackjackUI BlackjackTable.tsx pages/ PlayerDashboard.tsx ``` ## Adding a New Game 1. `shared/games//types.ts` — define state and action types 2. `shared/games//plugin.ts` — implement `GamePlugin` interface 3. `shared/games//plugin.test.ts` — test pure functions 4. `panel/src/games//Component.tsx` — React component 5. `panel/src/games//index.ts` — register UI plugin No changes to: RoomManager, ws-handler, useGameRoom, GameRoom.tsx, GameLobby.tsx, routing, or WebSocket code.