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
userstable), 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:
- Any Discord user can complete OAuth (remove the 403 gate)
- After getting Discord user info, look up their Discord ID in the
userstable - Not enrolled (no row): do not create a session. Return
{ authenticated: false, enrolled: false } - Enrolled: create a session with a
rolefield —"admin"if their ID is inADMIN_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
rolefromuseAuth
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
discordIdandroleto 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:
handleActionis a pure function — state in, state out. No side effects, trivially testable.getPlayerViewhandles information hiding — chess shows everything, blackjack hides opponent hands.getSpectatorViewprovides a public-safe view.- Generic
TState/TActiontypes 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
-
Create — player sends
CREATE_ROOM { gameType }. RoomManager generates UUID, creates room in"waiting"status, adds creator as first player. BroadcastsROOM_LIST_UPDATEto lobby. Returns room ID — client navigates to/:gameSlug/:roomId. -
Join — player sends
JOIN_ROOM { roomId, as: "player" }. Validates room exists, not full, status is"waiting". Adds toplayers. BroadcastsPLAYER_JOINED. Ifplayers.length === maxPlayers, auto-start. -
Start — calls
plugin.createInitialState(players), sets status to"playing". Sends each player theirgetPlayerView, spectators theirgetSpectatorView. -
Action — player sends
GAME_ACTION { roomId, action }. RoomManager callsplugin.handleAction(state, action, playerId). Ifok: update state, checkisGameOver(), broadcast views. If notok: sendERRORto that player only. -
End —
isGameOver()returns a result. Set status to"finished", broadcastGAME_ENDED. Clean up room after 60 seconds. -
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
lobbyWebSocket 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
shared/games/<name>/types.ts— define state and action typesshared/games/<name>/plugin.ts— implementGamePlugininterfaceshared/games/<name>/plugin.test.ts— test pure functionspanel/src/games/<name>/Component.tsx— React componentpanel/src/games/<name>/index.ts— register UI plugin
No changes to: RoomManager, ws-handler, useGameRoom, GameRoom.tsx, GameLobby.tsx, routing, or WebSocket code.