docs: add web games platform design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 12:49:21 +02:00
parent 1e978dff58
commit 40ae93f68b

View File

@@ -0,0 +1,341 @@
# 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, options? }` |
| `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.