# 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:<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/`)

```typescript
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/`)

```typescript
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

```typescript
// 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

```typescript
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. **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:<roomId>` 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/<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.
