docs: add web games platform design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
341
docs/superpowers/specs/2026-04-02-web-games-platform-design.md
Normal file
341
docs/superpowers/specs/2026-04-02-web-games-platform-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user