Files
aurorabot/docs/superpowers/specs/2026-04-02-web-games-platform-design.md
syntaxbullet d3e83bac66 docs: remove ambiguous options field from CREATE_ROOM message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:49:42 +02:00

13 KiB

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

// Before
{ discordId, username, avatar, expiresAt }

// After
{ discordId, username, avatar, role: "admin" | "player", expiresAt }

/auth/me Response

{ 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:<roomId> 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:<id> Full view-filtered state snapshot
GAME_UPDATE room:<id> New view-filtered state after action
PLAYER_JOINED room:<id> { player: PlayerInfo, as: "player" | "spectator" }
PLAYER_LEFT room:<id> { playerId: string }
GAME_STARTED room:<id> Initial game state
GAME_ENDED room:<id> { 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/)

interface GamePlugin<TState, TAction> {
  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<TState>;
  getPlayerView(state: TState, playerId: string): Partial<TState>;
  getSpectatorView(state: TState): Partial<TState>;

  isGameOver?(state: TState): GameOverResult | null;
  onPlayerDisconnect?(state: TState, playerId: string): TState;
}

type GameResult<TState> =
  | { 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/)

interface GameUIPlugin {
  slug: string;
  name: string;
  component: React.ComponentType<GameUIProps>;
  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

// shared/games/registry.ts
const games = new Map<string, GamePlugin<any, any>>();

export const gameRegistry = {
  register(plugin: GamePlugin<any, any>) { 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

interface Room {
  id: string;                    // UUID
  gameSlug: string;
  host: string;                  // discordId of creator
  players: string[];             // discordIds of active players
  spectators: Set<string>;
  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. EndisGameOver() 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:<roomId> WebSocket channel
  • Room not found: friendly error page with link back to lobby

Shared Hook: useGameRoom

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/<name>/types.ts — define state and action types
  2. shared/games/<name>/plugin.ts — implement GamePlugin interface
  3. shared/games/<name>/plugin.test.ts — test pure functions
  4. panel/src/games/<name>/Component.tsx — React component
  5. panel/src/games/<name>/index.ts — register UI plugin

No changes to: RoomManager, ws-handler, useGameRoom, GameRoom.tsx, GameLobby.tsx, routing, or WebSocket code.