Files
aurorabot/docs/superpowers/plans/2026-04-02-web-games-platform.md
syntaxbullet 29b6153777 docs: add web games platform implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:58:07 +02:00

103 KiB

Web Games Platform Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extend the Aurora web panel with a player dashboard and multiplayer game system using a plugin architecture that makes adding new games trivial.

Architecture: Two-tier auth (admin/player) via Discord OAuth, React Router for URL-based navigation, WebSocket pub/sub channels for real-time game state, and a GamePlugin interface where each game is a set of pure functions (server) + React component (client). RoomManager coordinates rooms generically without knowing game-specific logic.

Tech Stack: React 19, React Router 7, Bun WebSocket, Zod validation, existing Drizzle ORM for user lookups

Spec: docs/superpowers/specs/2026-04-02-web-games-platform-design.md


File Map

New Files

shared/games/types.ts                          — GamePlugin, GameResult, GameOverResult interfaces
shared/games/registry.ts                       — gameRegistry singleton (register, get, list)
shared/games/chess/types.ts                    — ChessState, ChessAction, piece types
shared/games/chess/plugin.ts                   — Chess GamePlugin implementation
shared/games/chess/plugin.test.ts              — Tests for chess pure functions

api/src/games/types.ts                         — Room, WsClientMessage, WsServerMessage types
api/src/games/RoomManager.ts                   — Room CRUD, action routing, cleanup timers
api/src/games/RoomManager.test.ts              — RoomManager unit tests
api/src/games/ws-handler.ts                    — WebSocket message router for game messages

panel/src/lib/useWebSocket.ts                  — Shared WS connection + message routing
panel/src/lib/useGameRoom.ts                   — Per-room hook (join, leave, sendAction, state)
panel/src/games/registry.ts                    — Client-side GameUIPlugin registry
panel/src/games/GameLobby.tsx                  — Room list + create dialog
panel/src/games/GameRoom.tsx                   — Generic room wrapper + renders plugin component
panel/src/games/chess/index.ts                 — Registers ChessUI plugin
panel/src/games/chess/ChessBoard.tsx           — Chess React component
panel/src/pages/PlayerDashboard.tsx            — Player stats, inventory, activity
panel/src/pages/NotEnrolled.tsx                — "Use the bot first" page

Modified Files

api/src/routes/auth.routes.ts                  — Remove admin gate, add enrollment check, add role to session
api/src/routes/index.ts                        — Add role-based route protection (admin vs player)
api/src/server.ts                              — Integrate game WS handler, raise connection limit
shared/modules/dashboard/dashboard.types.ts    — Add game WS message types to schema

panel/package.json                             — Add react-router-dom dependency
panel/src/App.tsx                              — Replace state routing with React Router
panel/src/components/Layout.tsx                — Accept role prop, render different nav per role
panel/src/lib/useAuth.ts                       — Add role + enrolled fields
panel/vite.config.ts                           — No changes needed (proxy already handles /ws)

Task 1: Game Plugin Interface & Registry

Files:

  • Create: shared/games/types.ts

  • Create: shared/games/registry.ts

  • Step 1: Create the GamePlugin interface

// shared/games/types.ts

export interface GamePlugin<TState = unknown, TAction = unknown> {
    slug: string;
    name: string;
    minPlayers: number;
    maxPlayers: number;

    createInitialState(players: string[]): TState;
    handleAction(state: TState, action: TAction, playerId: string): GameResult<TState>;
    getPlayerView(state: TState, playerId: string): unknown;
    getSpectatorView(state: TState): unknown;

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

export type GameResult<TState> =
    | { ok: true; state: TState }
    | { ok: false; error: string };

export type GameOverResult = {
    winner: string | null;
    reason: string;
};
  • Step 2: Create the game registry
// shared/games/registry.ts

import type { GamePlugin } from "./types";

const games = new Map<string, GamePlugin>();

export const gameRegistry = {
    register(plugin: GamePlugin) {
        if (games.has(plugin.slug)) {
            throw new Error(`Game "${plugin.slug}" is already registered`);
        }
        games.set(plugin.slug, plugin);
    },
    get(slug: string): GamePlugin | undefined {
        return games.get(slug);
    },
    list(): GamePlugin[] {
        return Array.from(games.values());
    },
};
  • Step 3: Commit
git add shared/games/types.ts shared/games/registry.ts
git commit -m "feat(games): add GamePlugin interface and registry"

Task 2: Chess Plugin — Server-Side

Files:

  • Create: shared/games/chess/types.ts

  • Create: shared/games/chess/plugin.ts

  • Create: shared/games/chess/plugin.test.ts

  • Step 1: Define chess types

// shared/games/chess/types.ts

export type PieceColor = "white" | "black";
export type PieceType = "pawn" | "rook" | "knight" | "bishop" | "queen" | "king";

export interface Piece {
    type: PieceType;
    color: PieceColor;
}

export interface ChessState {
    board: (Piece | null)[][];       // 8x8 grid, board[row][col]
    currentTurn: PieceColor;
    players: { white: string; black: string };  // discordIds
    moveHistory: string[];           // algebraic notation
    status: "playing" | "checkmate" | "stalemate" | "forfeit";
    winner: string | null;
}

export type ChessAction =
    | { type: "move"; from: [number, number]; to: [number, number] }
    | { type: "forfeit" };
  • Step 2: Write failing tests for chess plugin
// shared/games/chess/plugin.test.ts

import { describe, it, expect } from "bun:test";
import { chessPlugin } from "./plugin";
import type { ChessState, ChessAction } from "./types";

describe("chessPlugin", () => {
    const PLAYER_WHITE = "player1";
    const PLAYER_BLACK = "player2";

    describe("metadata", () => {
        it("should have correct slug and player counts", () => {
            expect(chessPlugin.slug).toBe("chess");
            expect(chessPlugin.minPlayers).toBe(2);
            expect(chessPlugin.maxPlayers).toBe(2);
        });
    });

    describe("createInitialState", () => {
        it("should create a board with pieces in starting positions", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            expect(state.board.length).toBe(8);
            expect(state.board[0].length).toBe(8);
            expect(state.currentTurn).toBe("white");
            expect(state.players.white).toBe(PLAYER_WHITE);
            expect(state.players.black).toBe(PLAYER_BLACK);
            expect(state.moveHistory).toEqual([]);
            expect(state.status).toBe("playing");
        });

        it("should place white pawns on row 6 and black pawns on row 1", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            for (let col = 0; col < 8; col++) {
                expect(state.board[6][col]).toEqual({ type: "pawn", color: "white" });
                expect(state.board[1][col]).toEqual({ type: "pawn", color: "black" });
            }
        });

        it("should place rooks in corners", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            expect(state.board[0][0]).toEqual({ type: "rook", color: "black" });
            expect(state.board[0][7]).toEqual({ type: "rook", color: "black" });
            expect(state.board[7][0]).toEqual({ type: "rook", color: "white" });
            expect(state.board[7][7]).toEqual({ type: "rook", color: "white" });
        });
    });

    describe("handleAction — move", () => {
        it("should allow white pawn to move forward on white's turn", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const result = chessPlugin.handleAction(state, { type: "move", from: [6, 4], to: [4, 4] }, PLAYER_WHITE);
            expect(result.ok).toBe(true);
            if (result.ok) {
                expect(result.state.board[4][4]).toEqual({ type: "pawn", color: "white" });
                expect(result.state.board[6][4]).toBeNull();
                expect(result.state.currentTurn).toBe("black");
            }
        });

        it("should reject move when it is not the player's turn", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            // Black tries to move on white's turn — should be rejected
            const result = chessPlugin.handleAction(state, { type: "move", from: [1, 4], to: [3, 4] }, PLAYER_BLACK);
            expect(result.ok).toBe(false);
        });

        it("should reject moving opponent's piece", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            // White tries to move a black pawn
            const result = chessPlugin.handleAction(state, { type: "move", from: [1, 4], to: [3, 4] }, PLAYER_WHITE);
            expect(result.ok).toBe(false);
        });

        it("should reject move from empty square", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const result = chessPlugin.handleAction(state, { type: "move", from: [4, 4], to: [3, 4] }, PLAYER_WHITE);
            expect(result.ok).toBe(false);
        });

        it("should reject out-of-bounds coordinates", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const result = chessPlugin.handleAction(state, { type: "move", from: [8, 0], to: [7, 0] }, PLAYER_WHITE);
            expect(result.ok).toBe(false);
        });
    });

    describe("handleAction — forfeit", () => {
        it("should end the game with the other player as winner", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const result = chessPlugin.handleAction(state, { type: "forfeit" }, PLAYER_WHITE);
            expect(result.ok).toBe(true);
            if (result.ok) {
                expect(result.state.status).toBe("forfeit");
                expect(result.state.winner).toBe(PLAYER_BLACK);
            }
        });
    });

    describe("getPlayerView", () => {
        it("should return full state (chess has no hidden info)", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const view = chessPlugin.getPlayerView(state, PLAYER_WHITE);
            expect(view).toEqual(state);
        });
    });

    describe("getSpectatorView", () => {
        it("should return full state", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const view = chessPlugin.getSpectatorView(state);
            expect(view).toEqual(state);
        });
    });

    describe("isGameOver", () => {
        it("should return null for ongoing game", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const result = chessPlugin.isGameOver!(state);
            expect(result).toBeNull();
        });

        it("should return winner for forfeit", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            state.status = "forfeit";
            state.winner = PLAYER_BLACK;
            const result = chessPlugin.isGameOver!(state);
            expect(result).toEqual({ winner: PLAYER_BLACK, reason: "forfeit" });
        });
    });

    describe("onPlayerDisconnect", () => {
        it("should forfeit the disconnected player", () => {
            const state = chessPlugin.createInitialState([PLAYER_WHITE, PLAYER_BLACK]);
            const result = chessPlugin.onPlayerDisconnect!(state, PLAYER_WHITE);
            expect(result.status).toBe("forfeit");
            expect(result.winner).toBe(PLAYER_BLACK);
        });
    });
});
  • Step 3: Run tests to verify they fail

Run: bun test shared/games/chess/plugin.test.ts Expected: FAIL — chessPlugin module not found

  • Step 4: Implement chess plugin
// shared/games/chess/plugin.ts

import type { GamePlugin, GameResult, GameOverResult } from "../types";
import type { ChessState, ChessAction, Piece, PieceColor, PieceType } from "./types";

const BACK_ROW: PieceType[] = ["rook", "knight", "bishop", "queen", "king", "bishop", "knight", "rook"];

function createStartingBoard(): (Piece | null)[][] {
    const board: (Piece | null)[][] = Array.from({ length: 8 }, () => Array(8).fill(null));

    for (let col = 0; col < 8; col++) {
        board[0][col] = { type: BACK_ROW[col], color: "black" };
        board[1][col] = { type: "pawn", color: "black" };
        board[6][col] = { type: "pawn", color: "white" };
        board[7][col] = { type: BACK_ROW[col], color: "white" };
    }

    return board;
}

function inBounds(row: number, col: number): boolean {
    return row >= 0 && row < 8 && col >= 0 && col < 8;
}

function getPlayerColor(state: ChessState, playerId: string): PieceColor | null {
    if (state.players.white === playerId) return "white";
    if (state.players.black === playerId) return "black";
    return null;
}

function isValidMove(board: (Piece | null)[][], from: [number, number], to: [number, number], piece: Piece): boolean {
    const [fromRow, fromCol] = from;
    const [toRow, toCol] = to;
    const target = board[toRow][toCol];

    // Can't capture own piece
    if (target && target.color === piece.color) return false;

    const rowDiff = toRow - fromRow;
    const colDiff = toCol - fromCol;
    const absRow = Math.abs(rowDiff);
    const absCol = Math.abs(colDiff);

    switch (piece.type) {
        case "pawn": {
            const direction = piece.color === "white" ? -1 : 1;
            const startRow = piece.color === "white" ? 6 : 1;

            // Forward one
            if (colDiff === 0 && rowDiff === direction && !target) return true;
            // Forward two from start
            if (colDiff === 0 && rowDiff === 2 * direction && fromRow === startRow && !target && !board[fromRow + direction][fromCol]) return true;
            // Capture diagonal
            if (absCol === 1 && rowDiff === direction && target) return true;

            return false;
        }
        case "rook":
            if (fromRow !== toRow && fromCol !== toCol) return false;
            return isPathClear(board, from, to);
        case "knight":
            return (absRow === 2 && absCol === 1) || (absRow === 1 && absCol === 2);
        case "bishop":
            if (absRow !== absCol) return false;
            return isPathClear(board, from, to);
        case "queen":
            if (fromRow !== toRow && fromCol !== toCol && absRow !== absCol) return false;
            return isPathClear(board, from, to);
        case "king":
            return absRow <= 1 && absCol <= 1;
        default:
            return false;
    }
}

function isPathClear(board: (Piece | null)[][], from: [number, number], to: [number, number]): boolean {
    const [fromRow, fromCol] = from;
    const [toRow, toCol] = to;
    const rowStep = Math.sign(toRow - fromRow);
    const colStep = Math.sign(toCol - fromCol);

    let row = fromRow + rowStep;
    let col = fromCol + colStep;
    while (row !== toRow || col !== toCol) {
        if (board[row][col]) return false;
        row += rowStep;
        col += colStep;
    }
    return true;
}

function toAlgebraic(from: [number, number], to: [number, number], piece: Piece, captured: boolean): string {
    const files = "abcdefgh";
    const prefix = piece.type === "pawn" ? "" : piece.type[0].toUpperCase();
    const cap = captured ? "x" : "";
    const fromStr = piece.type === "pawn" && captured ? files[from[1]] : "";
    return `${prefix}${fromStr}${cap}${files[to[1]]}${8 - to[0]}`;
}

export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
    slug: "chess",
    name: "Chess",
    minPlayers: 2,
    maxPlayers: 2,

    createInitialState(players: string[]): ChessState {
        return {
            board: createStartingBoard(),
            currentTurn: "white",
            players: { white: players[0], black: players[1] },
            moveHistory: [],
            status: "playing",
            winner: null,
        };
    },

    handleAction(state: ChessState, action: ChessAction, playerId: string): GameResult<ChessState> {
        if (state.status !== "playing") {
            return { ok: false, error: "Game is already over" };
        }

        if (action.type === "forfeit") {
            const color = getPlayerColor(state, playerId);
            if (!color) return { ok: false, error: "You are not a player in this game" };
            const winner = color === "white" ? state.players.black : state.players.white;
            return {
                ok: true,
                state: { ...state, status: "forfeit", winner },
            };
        }

        if (action.type === "move") {
            const { from, to } = action;

            if (!inBounds(from[0], from[1]) || !inBounds(to[0], to[1])) {
                return { ok: false, error: "Coordinates out of bounds" };
            }

            const piece = state.board[from[0]][from[1]];
            if (!piece) {
                return { ok: false, error: "No piece at source square" };
            }

            const playerColor = getPlayerColor(state, playerId);
            if (!playerColor) {
                return { ok: false, error: "You are not a player in this game" };
            }
            if (playerColor !== state.currentTurn) {
                return { ok: false, error: "It is not your turn" };
            }
            if (piece.color !== playerColor) {
                return { ok: false, error: "That is not your piece" };
            }

            if (!isValidMove(state.board, from, to, piece)) {
                return { ok: false, error: "Invalid move" };
            }

            // Apply move (immutable)
            const newBoard = state.board.map(row => [...row]);
            const captured = newBoard[to[0]][to[1]];
            newBoard[to[0]][to[1]] = piece;
            newBoard[from[0]][from[1]] = null;

            const notation = toAlgebraic(from, to, piece, captured !== null);
            const nextTurn: PieceColor = state.currentTurn === "white" ? "black" : "white";

            return {
                ok: true,
                state: {
                    ...state,
                    board: newBoard,
                    currentTurn: nextTurn,
                    moveHistory: [...state.moveHistory, notation],
                },
            };
        }

        return { ok: false, error: "Unknown action type" };
    },

    getPlayerView(state: ChessState, _playerId: string): ChessState {
        return state;
    },

    getSpectatorView(state: ChessState): ChessState {
        return state;
    },

    isGameOver(state: ChessState): GameOverResult | null {
        if (state.status === "playing") return null;
        return { winner: state.winner, reason: state.status };
    },

    onPlayerDisconnect(state: ChessState, playerId: string): ChessState {
        const color = getPlayerColor(state, playerId);
        if (!color) return state;
        const winner = color === "white" ? state.players.black : state.players.white;
        return { ...state, status: "forfeit", winner };
    },
};
  • Step 5: Run tests to verify they pass

Run: bun test shared/games/chess/plugin.test.ts Expected: All tests PASS

  • Step 6: Register chess in the registry (smoke test)

Add a quick manual check — open a Bun REPL or add a temporary test:

// Add to the bottom of shared/games/chess/plugin.test.ts

import { gameRegistry } from "../registry";

describe("chess registration", () => {
    it("should register and retrieve from gameRegistry", () => {
        gameRegistry.register(chessPlugin);
        expect(gameRegistry.get("chess")).toBe(chessPlugin);
        expect(gameRegistry.list()).toContain(chessPlugin);
    });
});

Run: bun test shared/games/chess/plugin.test.ts Expected: All tests PASS

  • Step 7: Commit
git add shared/games/chess/
git commit -m "feat(games): implement chess plugin with tests"

Task 3: Game Room Types & WebSocket Message Schema

Files:

  • Create: api/src/games/types.ts

  • Modify: shared/modules/dashboard/dashboard.types.ts

  • Step 1: Create game room and WS message types

// api/src/games/types.ts

import { z } from "zod";

// --- Room types ---

export interface Room {
    id: string;
    gameSlug: string;
    host: string;
    players: string[];
    spectators: Set<string>;
    state: unknown;
    status: "waiting" | "playing" | "finished";
    createdAt: number;
}

export interface RoomSummary {
    id: string;
    gameSlug: string;
    gameName: string;
    host: string;
    playerCount: number;
    maxPlayers: number;
    spectatorCount: number;
    status: "waiting" | "playing" | "finished";
}

export interface PlayerInfo {
    discordId: string;
    username: string;
}

// --- Client → Server messages ---

export const GameWsClientSchema = z.discriminatedUnion("type", [
    z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }),
    z.object({ type: z.literal("JOIN_ROOM"), roomId: z.string(), as: z.enum(["player", "spectator"]) }),
    z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
    z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.record(z.unknown()) }),
]);

export type GameWsClientMessage = z.infer<typeof GameWsClientSchema>;

// --- Server → Client messages ---

export type GameWsServerMessage =
    | { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
    | { type: "GAME_STATE"; roomId: string; state: unknown }
    | { type: "GAME_UPDATE"; roomId: string; state: unknown }
    | { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; as: "player" | "spectator" }
    | { type: "PLAYER_LEFT"; roomId: string; playerId: string }
    | { type: "GAME_STARTED"; roomId: string; state: unknown }
    | { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string }
    | { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
    | { type: "ERROR"; message: string };
  • Step 2: Extend WsMessageSchema to include game messages

In shared/modules/dashboard/dashboard.types.ts, update the WsMessageSchema at lines 108-113 to include game messages alongside existing ones:

// shared/modules/dashboard/dashboard.types.ts
// Replace lines 108-113 with:

export const WsMessageSchema = z.discriminatedUnion("type", [
    z.object({ type: z.literal("PING") }),
    z.object({ type: z.literal("PONG") }),
    z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
    z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
    // Game messages (client → server)
    z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string() }),
    z.object({ type: z.literal("JOIN_ROOM"), roomId: z.string(), as: z.enum(["player", "spectator"]) }),
    z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
    z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.record(z.unknown()) }),
]);
  • Step 3: Commit
git add api/src/games/types.ts shared/modules/dashboard/dashboard.types.ts
git commit -m "feat(games): add room types and game WS message schemas"

Task 4: RoomManager

Files:

  • Create: api/src/games/RoomManager.ts

  • Create: api/src/games/RoomManager.test.ts

  • Step 1: Write failing tests for RoomManager

// api/src/games/RoomManager.test.ts

import { describe, it, expect, beforeEach } from "bun:test";
import { RoomManager } from "./RoomManager";
import { gameRegistry } from "@shared/games/registry";
import { chessPlugin } from "@shared/games/chess/plugin";
import type { RoomSummary } from "./types";

// Register chess plugin for tests
if (!gameRegistry.get("chess")) {
    gameRegistry.register(chessPlugin);
}

describe("RoomManager", () => {
    let manager: RoomManager;

    beforeEach(() => {
        manager = new RoomManager();
    });

    describe("createRoom", () => {
        it("should create a room and return its id", () => {
            const result = manager.createRoom("chess", "player1");
            expect(result.ok).toBe(true);
            if (result.ok) {
                expect(result.roomId).toBeDefined();
                expect(typeof result.roomId).toBe("string");
            }
        });

        it("should reject unknown game type", () => {
            const result = manager.createRoom("unknown-game", "player1");
            expect(result.ok).toBe(false);
        });

        it("should add creator as first player", () => {
            const result = manager.createRoom("chess", "player1");
            if (result.ok) {
                const room = manager.getRoom(result.roomId);
                expect(room?.players).toContain("player1");
                expect(room?.host).toBe("player1");
                expect(room?.status).toBe("waiting");
            }
        });
    });

    describe("joinRoom", () => {
        it("should add a player to a waiting room", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");

            const join = manager.joinRoom(create.roomId, "player2", "player");
            expect(join.ok).toBe(true);
        });

        it("should auto-start when room reaches maxPlayers", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");

            manager.joinRoom(create.roomId, "player2", "player");
            const room = manager.getRoom(create.roomId);
            expect(room?.status).toBe("playing");
            expect(room?.state).toBeDefined();
        });

        it("should allow joining as spectator when game is playing", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");

            manager.joinRoom(create.roomId, "player2", "player");
            const spec = manager.joinRoom(create.roomId, "spectator1", "spectator");
            expect(spec.ok).toBe(true);
        });

        it("should reject joining full room as player", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");

            manager.joinRoom(create.roomId, "player2", "player");
            const result = manager.joinRoom(create.roomId, "player3", "player");
            expect(result.ok).toBe(false);
        });

        it("should reject joining nonexistent room", () => {
            const result = manager.joinRoom("fake-id", "player1", "player");
            expect(result.ok).toBe(false);
        });
    });

    describe("handleAction", () => {
        it("should apply a valid game action", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");
            manager.joinRoom(create.roomId, "player2", "player");

            const result = manager.handleAction(create.roomId, "player1", { type: "move", from: [6, 4], to: [4, 4] });
            expect(result.ok).toBe(true);
        });

        it("should reject action from spectator", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");
            manager.joinRoom(create.roomId, "player2", "player");
            manager.joinRoom(create.roomId, "spectator1", "spectator");

            const result = manager.handleAction(create.roomId, "spectator1", { type: "move", from: [6, 4], to: [4, 4] });
            expect(result.ok).toBe(false);
        });
    });

    describe("leaveRoom", () => {
        it("should remove a player from the room", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");

            manager.leaveRoom(create.roomId, "player1");
            const room = manager.getRoom(create.roomId);
            expect(room?.players).not.toContain("player1");
        });

        it("should remove a spectator from the room", () => {
            const create = manager.createRoom("chess", "player1");
            if (!create.ok) throw new Error("Failed to create room");
            manager.joinRoom(create.roomId, "player2", "player");
            manager.joinRoom(create.roomId, "spec1", "spectator");

            manager.leaveRoom(create.roomId, "spec1");
            const room = manager.getRoom(create.roomId);
            expect(room?.spectators.has("spec1")).toBe(false);
        });
    });

    describe("listRooms", () => {
        it("should return summaries of all rooms", () => {
            manager.createRoom("chess", "player1");
            manager.createRoom("chess", "player2");

            const rooms = manager.listRooms();
            expect(rooms.length).toBe(2);
            expect(rooms[0].gameSlug).toBe("chess");
            expect(rooms[0].status).toBe("waiting");
        });

        it("should filter by game type", () => {
            manager.createRoom("chess", "player1");
            const rooms = manager.listRooms("chess");
            expect(rooms.length).toBe(1);
            const empty = manager.listRooms("blackjack");
            expect(empty.length).toBe(0);
        });
    });
});
  • Step 2: Run tests to verify they fail

Run: bun test api/src/games/RoomManager.test.ts Expected: FAIL — RoomManager module not found

  • Step 3: Implement RoomManager
// api/src/games/RoomManager.ts

import { gameRegistry } from "@shared/games/registry";
import type { Room, RoomSummary } from "./types";

type ActionResult =
    | { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null }
    | { ok: false; error: string };

type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
type JoinResult = { ok: true; started: boolean } | { ok: false; error: string };

export class RoomManager {
    private rooms = new Map<string, Room>();
    private cleanupTimers = new Map<string, Timer>();

    createRoom(gameSlug: string, hostId: string): CreateResult {
        const plugin = gameRegistry.get(gameSlug);
        if (!plugin) {
            return { ok: false, error: `Unknown game type: ${gameSlug}` };
        }

        const id = crypto.randomUUID();
        const room: Room = {
            id,
            gameSlug,
            host: hostId,
            players: [hostId],
            spectators: new Set(),
            state: null,
            status: "waiting",
            createdAt: Date.now(),
        };

        this.rooms.set(id, room);
        this.scheduleCleanup(id, 60_000);
        return { ok: true, roomId: id };
    }

    joinRoom(roomId: string, playerId: string, as: "player" | "spectator"): JoinResult {
        const room = this.rooms.get(roomId);
        if (!room) return { ok: false, error: "Room not found" };

        if (as === "spectator") {
            room.spectators.add(playerId);
            return { ok: true, started: false };
        }

        // Joining as player
        if (room.status !== "waiting") {
            return { ok: false, error: "Game already started" };
        }

        const plugin = gameRegistry.get(room.gameSlug)!;
        if (room.players.length >= plugin.maxPlayers) {
            return { ok: false, error: "Room is full" };
        }

        if (room.players.includes(playerId)) {
            return { ok: false, error: "Already in room" };
        }

        room.players.push(playerId);

        // Auto-start if full
        if (room.players.length >= plugin.maxPlayers) {
            room.state = plugin.createInitialState(room.players);
            room.status = "playing";
            this.clearCleanup(roomId);
            return { ok: true, started: true };
        }

        return { ok: true, started: false };
    }

    handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
        const room = this.rooms.get(roomId);
        if (!room) return { ok: false, error: "Room not found" };
        if (room.status !== "playing") return { ok: false, error: "Game is not in progress" };

        if (!room.players.includes(playerId)) {
            return { ok: false, error: "You are not a player in this game" };
        }

        const plugin = gameRegistry.get(room.gameSlug)!;
        const result = plugin.handleAction(room.state, action, playerId);

        if (!result.ok) return result;

        room.state = result.state;

        const gameOver = plugin.isGameOver?.(room.state) ?? null;
        if (gameOver) {
            room.status = "finished";
            this.scheduleCleanup(roomId, 60_000);
        }

        return { ok: true, state: room.state, gameOver };
    }

    leaveRoom(roomId: string, playerId: string): void {
        const room = this.rooms.get(roomId);
        if (!room) return;

        if (room.spectators.has(playerId)) {
            room.spectators.delete(playerId);
            return;
        }

        const playerIdx = room.players.indexOf(playerId);
        if (playerIdx === -1) return;

        room.players.splice(playerIdx, 1);

        // If game is playing and a player leaves, handle disconnect
        if (room.status === "playing") {
            const plugin = gameRegistry.get(room.gameSlug)!;
            if (plugin.onPlayerDisconnect) {
                room.state = plugin.onPlayerDisconnect(room.state, playerId);
                const gameOver = plugin.isGameOver?.(room.state) ?? null;
                if (gameOver) {
                    room.status = "finished";
                    this.scheduleCleanup(roomId, 60_000);
                }
            }
        }

        // Clean up empty waiting rooms immediately
        if (room.players.length === 0 && room.status === "waiting") {
            this.deleteRoom(roomId);
        }
    }

    getRoom(roomId: string): Room | undefined {
        return this.rooms.get(roomId);
    }

    listRooms(gameSlug?: string): RoomSummary[] {
        const summaries: RoomSummary[] = [];
        for (const room of this.rooms.values()) {
            if (gameSlug && room.gameSlug !== gameSlug) continue;
            const plugin = gameRegistry.get(room.gameSlug);
            summaries.push({
                id: room.id,
                gameSlug: room.gameSlug,
                gameName: plugin?.name ?? room.gameSlug,
                host: room.host,
                playerCount: room.players.length,
                maxPlayers: plugin?.maxPlayers ?? 0,
                spectatorCount: room.spectators.size,
                status: room.status,
            });
        }
        return summaries;
    }

    getPlayerView(roomId: string, playerId: string): unknown {
        const room = this.rooms.get(roomId);
        if (!room || !room.state) return null;
        const plugin = gameRegistry.get(room.gameSlug)!;
        return plugin.getPlayerView(room.state, playerId);
    }

    getSpectatorView(roomId: string): unknown {
        const room = this.rooms.get(roomId);
        if (!room || !room.state) return null;
        const plugin = gameRegistry.get(room.gameSlug)!;
        return plugin.getSpectatorView(room.state);
    }

    private scheduleCleanup(roomId: string, ms: number): void {
        this.clearCleanup(roomId);
        const timer = setTimeout(() => this.deleteRoom(roomId), ms);
        this.cleanupTimers.set(roomId, timer);
    }

    private clearCleanup(roomId: string): void {
        const existing = this.cleanupTimers.get(roomId);
        if (existing) {
            clearTimeout(existing);
            this.cleanupTimers.delete(roomId);
        }
    }

    private deleteRoom(roomId: string): void {
        this.clearCleanup(roomId);
        this.rooms.delete(roomId);
    }
}
  • Step 4: Run tests to verify they pass

Run: bun test api/src/games/RoomManager.test.ts Expected: All tests PASS

  • Step 5: Commit
git add api/src/games/
git commit -m "feat(games): implement RoomManager with room lifecycle and tests"

Task 5: WebSocket Game Handler & Server Integration

Files:

  • Create: api/src/games/ws-handler.ts

  • Modify: api/src/server.ts

  • Step 1: Create the WebSocket game message handler

This module receives parsed game messages and routes them through the RoomManager, sending responses back via the server's publish/send mechanisms.

// api/src/games/ws-handler.ts

import { RoomManager } from "./RoomManager";
import type { GameWsClientMessage, PlayerInfo } from "./types";
import { logger } from "@shared/lib/logger";

export const roomManager = new RoomManager();

interface WsContext {
    playerId: string;
    username: string;
    send: (data: string) => void;
    subscribe: (channel: string) => void;
    unsubscribe: (channel: string) => void;
    publish: (channel: string, data: string) => void;
}

export function handleGameMessage(msg: GameWsClientMessage, ctx: WsContext): void {
    switch (msg.type) {
        case "CREATE_ROOM": {
            const result = roomManager.createRoom(msg.gameType, ctx.playerId);
            if (!result.ok) {
                ctx.send(JSON.stringify({ type: "ERROR", message: result.error }));
                return;
            }
            ctx.subscribe(`room:${result.roomId}`);
            ctx.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
            ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
            logger.debug("games", `Room created: ${result.roomId} (${msg.gameType}) by ${ctx.playerId}`);
            break;
        }

        case "JOIN_ROOM": {
            const result = roomManager.joinRoom(msg.roomId, ctx.playerId, msg.as);
            if (!result.ok) {
                ctx.send(JSON.stringify({ type: "ERROR", message: result.error }));
                return;
            }

            ctx.subscribe(`room:${msg.roomId}`);
            const playerInfo: PlayerInfo = { discordId: ctx.playerId, username: ctx.username };
            ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "PLAYER_JOINED", roomId: msg.roomId, player: playerInfo, as: msg.as }));

            if (result.started) {
                // Game auto-started — send each participant their view
                const room = roomManager.getRoom(msg.roomId)!;
                // Broadcast game started with spectator view on the channel
                const spectatorView = roomManager.getSpectatorView(msg.roomId);
                ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_STARTED", roomId: msg.roomId, state: spectatorView }));
            } else {
                // Send current game state if joining mid-game as spectator
                const room = roomManager.getRoom(msg.roomId);
                if (room && room.status === "playing") {
                    const view = msg.as === "spectator"
                        ? roomManager.getSpectatorView(msg.roomId)
                        : roomManager.getPlayerView(msg.roomId, ctx.playerId);
                    ctx.send(JSON.stringify({ type: "GAME_STATE", roomId: msg.roomId, state: view }));
                }
            }

            ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
            break;
        }

        case "LEAVE_ROOM": {
            roomManager.leaveRoom(msg.roomId, ctx.playerId);
            ctx.unsubscribe(`room:${msg.roomId}`);
            ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "PLAYER_LEFT", roomId: msg.roomId, playerId: ctx.playerId }));
            ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
            break;
        }

        case "GAME_ACTION": {
            const result = roomManager.handleAction(msg.roomId, ctx.playerId, msg.action);
            if (!result.ok) {
                ctx.send(JSON.stringify({ type: "ERROR", message: result.error }));
                return;
            }

            // Broadcast updated spectator view to room
            const spectatorView = roomManager.getSpectatorView(msg.roomId);
            ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_UPDATE", roomId: msg.roomId, state: spectatorView }));

            if (result.gameOver) {
                ctx.publish(`room:${msg.roomId}`, JSON.stringify({
                    type: "GAME_ENDED",
                    roomId: msg.roomId,
                    winner: result.gameOver.winner,
                    reason: result.gameOver.reason,
                }));
                ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
            }
            break;
        }
    }
}
  • Step 2: Modify server.ts to integrate game WS handler

In api/src/server.ts, make the following changes:

  1. Add import at the top (after line 13):
import { handleGameMessage, roomManager } from "./games/ws-handler";
import { getSession } from "./routes/auth.routes";
  1. Raise MAX_CONNECTIONS from 10 to 200 (line 94):
const MAX_CONNECTIONS = 200;
  1. Add session validation on WS upgrade. Replace lines 108-117:
            // WebSocket upgrade handling
            if (url.pathname === "/ws") {
                const currentConnections = server.pendingWebSockets;
                if (currentConnections >= MAX_CONNECTIONS) {
                    logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
                    return new Response("Connection limit reached", { status: 429 });
                }

                // Attach session data to WS connection
                const session = getSession(req);
                const success = server.upgrade(req, { data: { session } });
                if (success) return undefined;
                return new Response("WebSocket upgrade failed", { status: 400 });
            }
  1. Update the open handler to subscribe to lobby channel (replace lines 138-157):
            open(ws) {
                ws.subscribe("dashboard");
                ws.subscribe("lobby");
                logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);

                // Send initial stats
                getFullDashboardStats().then(stats => {
                    ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
                });

                // Send initial room list
                ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));

                // Start broadcast interval if this is the first client
                if (!statsBroadcastInterval) {
                    statsBroadcastInterval = setInterval(async () => {
                        try {
                            const stats = await getFullDashboardStats();
                            server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
                        } catch (error) {
                            logger.error("web", "Error in stats broadcast", error);
                        }
                    }, 5000);
                }
            },
  1. Update the message handler to route game messages (replace lines 164-189):
            async message(ws, message) {
                try {
                    const messageStr = message.toString();

                    if (messageStr.length > MAX_PAYLOAD_BYTES) {
                        logger.error("web", "Payload exceeded maximum limit");
                        return;
                    }

                    const rawData = JSON.parse(messageStr);
                    const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
                    const parsed = WsMessageSchema.safeParse(rawData);

                    if (!parsed.success) {
                        logger.error("web", "Invalid message format", parsed.error.issues);
                        return;
                    }

                    if (parsed.data.type === "PING") {
                        ws.send(JSON.stringify({ type: "PONG" }));
                        return;
                    }

                    // Route game messages
                    const gameTypes = ["CREATE_ROOM", "JOIN_ROOM", "LEAVE_ROOM", "GAME_ACTION"] as const;
                    if (gameTypes.includes(parsed.data.type as any)) {
                        const sessionData = (ws.data as any)?.session;
                        if (!sessionData) {
                            ws.send(JSON.stringify({ type: "ERROR", message: "Not authenticated" }));
                            return;
                        }
                        handleGameMessage(parsed.data as any, {
                            playerId: sessionData.discordId,
                            username: sessionData.username,
                            send: (data) => ws.send(data),
                            subscribe: (channel) => ws.subscribe(channel),
                            unsubscribe: (channel) => ws.unsubscribe(channel),
                            publish: (channel, data) => server.publish(channel, data),
                        });
                    }
                } catch (e) {
                    logger.error("web", "Failed to handle message", e);
                }
            },
  1. Update close handler to unsubscribe from lobby (replace lines 195-204):
            close(ws) {
                ws.unsubscribe("dashboard");
                ws.unsubscribe("lobby");
                logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);

                if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
                    clearInterval(statsBroadcastInterval);
                    statsBroadcastInterval = undefined;
                }
            },
  • Step 3: Commit
git add api/src/games/ws-handler.ts api/src/server.ts
git commit -m "feat(games): integrate game WS handler into server"

Task 6: Auth — Remove Admin Gate, Add Enrollment Check & Roles

Files:

  • Modify: api/src/routes/auth.routes.ts

  • Modify: api/src/routes/index.ts

  • Step 1: Update Session interface and auth callback

In api/src/routes/auth.routes.ts:

  1. Add role to Session interface (replace lines 11-16):
export interface Session {
    discordId: string;
    username: string;
    avatar: string | null;
    role: "admin" | "player";
    expiresAt: number;
}
  1. Add DrizzleClient import after line 8:
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users } from "@shared/db/schema";
import { eq } from "drizzle-orm";
  1. Replace the allowlist check and session creation block (lines 147-164) with enrollment check + role assignment:
            // Check enrollment — user must exist in the users table
            const dbUser = await DrizzleClient.query.users.findFirst({
                where: eq(users.id, BigInt(user.id)),
            });

            if (!dbUser) {
                logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`);
                // Return a page that tells them to use the bot first
                return new Response(
                    `<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
                    { status: 403, headers: { "Content-Type": "text/html" } }
                );
            }

            // Determine role
            const adminIds = getAdminIds();
            const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";

            // Create session
            const token = generateToken();
            sessions.set(token, {
                discordId: user.id,
                username: user.username,
                avatar: user.avatar,
                role,
                expiresAt: Date.now() + SESSION_MAX_AGE,
            });

            logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
  1. Update /auth/me response (replace lines 214-225) to include role and enrolled:
    // GET /auth/me — return current session info
    if (pathname === "/auth/me" && method === "GET") {
        const session = getSession(ctx.req);
        if (!session) return jsonResponse({ authenticated: false, enrolled: false });
        return jsonResponse({
            authenticated: true,
            enrolled: true,
            user: {
                discordId: session.discordId,
                username: session.username,
                avatar: session.avatar,
                role: session.role,
            },
        });
    }
  • Step 2: Update route protection for role-based access

In api/src/routes/index.ts, update the auth check (lines 71-76) to allow players access to certain API routes while restricting admin-only routes:

    // For API routes, enforce authentication
    if (ctx.pathname.startsWith("/api/")) {
        const session = getSession(req);
        if (!session) {
            return errorResponse("Unauthorized", 401);
        }

        // Admin-only routes: everything except /api/users/:id (own profile) and /api/stats
        const playerAllowedPrefixes = ["/api/stats", "/api/health"];
        const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p));

        // Players can access their own user data
        const isOwnUserRoute = ctx.pathname.match(/^\/api\/users\/\d+/) && session.role === "player";

        if (session.role === "player" && !isPlayerAllowed && !isOwnUserRoute) {
            return errorResponse("Admin access required", 403);
        }
    }

Update the import at line 7 to also import getSession:

import { authRoutes, isAuthenticated, getSession } from "./auth.routes";
  • Step 3: Commit
git add api/src/routes/auth.routes.ts api/src/routes/index.ts
git commit -m "feat(auth): add enrollment check, role-based sessions, and player access"

Task 7: Panel — Install React Router & Update Auth Hook

Files:

  • Modify: panel/package.json (via bun add)

  • Modify: panel/src/lib/useAuth.ts

  • Create: panel/src/pages/NotEnrolled.tsx

  • Step 1: Install react-router-dom

Run: cd panel && bun add react-router-dom

  • Step 2: Update useAuth hook to include role and enrolled
// panel/src/lib/useAuth.ts

import { useState, useEffect } from "react";

export interface AuthUser {
    discordId: string;
    username: string;
    avatar: string | null;
    role: "admin" | "player";
}

interface AuthState {
    loading: boolean;
    user: AuthUser | null;
    enrolled: boolean;
}

export function useAuth(): AuthState & { logout: () => Promise<void> } {
    const [state, setState] = useState<AuthState>({ loading: true, user: null, enrolled: true });

    useEffect(() => {
        fetch("/auth/me", { credentials: "same-origin" })
            .then((r) => r.json())
            .then((data: { authenticated: boolean; enrolled: boolean; user?: AuthUser }) => {
                setState({
                    loading: false,
                    user: data.authenticated ? data.user! : null,
                    enrolled: data.enrolled ?? true,
                });
            })
            .catch(() => setState({ loading: false, user: null, enrolled: true }));
    }, []);

    const logout = async () => {
        await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
        setState({ loading: false, user: null, enrolled: true });
    };

    return { ...state, logout };
}
  • Step 3: Create NotEnrolled page
// panel/src/pages/NotEnrolled.tsx

export default function NotEnrolled() {
    return (
        <div className="min-h-screen flex items-center justify-center">
            <div className="text-center max-w-sm">
                <div className="font-display font-bold text-3xl tracking-tight mb-1">Aurora</div>
                <p className="text-sm text-text-tertiary mb-6">
                    You need to use the Aurora bot in Discord before you can access this panel.
                </p>
                <p className="text-xs text-text-disabled">
                    Use <span className="font-mono bg-surface px-1.5 py-0.5 rounded">/enroll</span> in any server with Aurora to get started.
                </p>
            </div>
        </div>
    );
}
  • Step 4: Commit
git add panel/package.json panel/bun.lock panel/src/lib/useAuth.ts panel/src/pages/NotEnrolled.tsx
git commit -m "feat(panel): add react-router-dom, update auth hook with roles, add NotEnrolled page"

Task 8: Panel — React Router & Layout Refactor

Files:

  • Modify: panel/src/App.tsx

  • Modify: panel/src/components/Layout.tsx

  • Step 1: Refactor Layout to accept role and use React Router navigation

// panel/src/components/Layout.tsx

import {
    LayoutDashboard,
    Users,
    Package,
    Shield,
    Scroll,
    Gift,
    ArrowLeftRight,
    GraduationCap,
    Settings,
    LogOut,
    ChevronLeft,
    ChevronRight,
    Gamepad2,
    Trophy,
} from "lucide-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { cn } from "../lib/utils";
import type { AuthUser } from "../lib/useAuth";

interface NavItem {
    path: string;
    label: string;
    icon: React.ComponentType<{ className?: string }>;
}

const adminNavItems: NavItem[] = [
    { path: "/admin", label: "Dashboard", icon: LayoutDashboard },
    { path: "/admin/users", label: "Users", icon: Users },
    { path: "/admin/items", label: "Items", icon: Package },
    { path: "/admin/classes", label: "Classes", icon: GraduationCap },
    { path: "/admin/quests", label: "Quests", icon: Scroll },
    { path: "/admin/lootdrops", label: "Lootdrops", icon: Gift },
    { path: "/admin/moderation", label: "Moderation", icon: Shield },
    { path: "/admin/transactions", label: "Transactions", icon: ArrowLeftRight },
    { path: "/admin/settings", label: "Settings", icon: Settings },
    { path: "/games", label: "Games", icon: Gamepad2 },
];

const playerNavItems: NavItem[] = [
    { path: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
    { path: "/games", label: "Games", icon: Gamepad2 },
    { path: "/leaderboards", label: "Leaderboards", icon: Trophy },
];

export default function Layout({
    user,
    logout,
    children,
}: {
    user: AuthUser;
    logout: () => Promise<void>;
    children: React.ReactNode;
}) {
    const [collapsed, setCollapsed] = useState(false);
    const location = useLocation();
    const navigate = useNavigate();

    const navItems = user.role === "admin" ? adminNavItems : playerNavItems;

    const avatarUrl = user.avatar
        ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
        : null;

    function isActive(path: string): boolean {
        if (path === "/admin" && location.pathname === "/admin") return true;
        if (path === "/dashboard" && location.pathname === "/dashboard") return true;
        if (path !== "/admin" && path !== "/dashboard" && location.pathname.startsWith(path)) return true;
        return false;
    }

    return (
        <div className="min-h-screen flex">
            <aside
                className={cn(
                    "fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
                    collapsed ? "w-16" : "w-60"
                )}
            >
                <div className="flex items-center h-16 px-4 border-b border-border">
                    <div className="font-display text-xl font-bold tracking-tight">
                        {collapsed ? "A" : "Aurora"}
                    </div>
                </div>

                <nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
                    {navItems.map(({ path, label, icon: Icon }) => (
                        <button
                            key={path}
                            onClick={() => navigate(path)}
                            className={cn(
                                "w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
                                isActive(path)
                                    ? "bg-primary/15 text-primary border-l-4 border-primary"
                                    : "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
                            )}
                        >
                            <Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} />
                            {!collapsed && <span>{label}</span>}
                        </button>
                    ))}
                </nav>

                <div className="border-t border-border p-3 space-y-2">
                    {!collapsed && (
                        <div className="flex items-center gap-3 px-2 py-1.5">
                            {avatarUrl ? (
                                <img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
                            ) : (
                                <div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
                                    {user.username[0]?.toUpperCase()}
                                </div>
                            )}
                            <div className="flex-1 min-w-0">
                                <div className="text-sm font-medium truncate">{user.username}</div>
                            </div>
                        </div>
                    )}
                    <div className={cn("flex", collapsed ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
                        <button
                            onClick={logout}
                            className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
                            title="Sign out"
                        >
                            <LogOut className="w-4 h-4" />
                            {!collapsed && <span>Sign out</span>}
                        </button>
                        <button
                            onClick={() => setCollapsed((c) => !c)}
                            className="p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
                            title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
                        >
                            {collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
                        </button>
                    </div>
                </div>
            </aside>

            <main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}>
                <div className="max-w-[1600px] mx-auto px-6 py-8">
                    {children}
                </div>
            </main>
        </div>
    );
}
  • Step 2: Rewrite App.tsx with React Router
// panel/src/App.tsx

import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./lib/useAuth";
import { Loader2 } from "lucide-react";
import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Settings from "./pages/Settings";
import Users from "./pages/Users";
import Items from "./pages/Items";
import PlaceholderPage from "./pages/PlaceholderPage";
import NotEnrolled from "./pages/NotEnrolled";
import PlayerDashboard from "./pages/PlayerDashboard";
import { GameLobby } from "./games/GameLobby";
import { GameRoom } from "./games/GameRoom";

const placeholders: Record<string, { title: string; description: string }> = {
    classes: {
        title: "Classes",
        description: "Manage academy classes, assign Discord roles, and track class balances.",
    },
    quests: {
        title: "Quests",
        description: "Configure quests with trigger events, targets, and XP/balance rewards.",
    },
    lootdrops: {
        title: "Lootdrops",
        description: "View active lootdrops, spawn new drops, and manage lootdrop history.",
    },
    moderation: {
        title: "Moderation",
        description: "Review moderation cases — warnings, timeouts, kicks, bans — and manage appeals.",
    },
    transactions: {
        title: "Transactions",
        description: "Browse the economy transaction log with filtering by user, type, and date.",
    },
    leaderboards: {
        title: "Leaderboards",
        description: "View top players by level, wealth, and net worth.",
    },
};

function AppRoutes() {
    const { loading, user, enrolled, logout } = useAuth();

    if (loading) {
        return (
            <div className="min-h-screen flex items-center justify-center">
                <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
            </div>
        );
    }

    if (!user && !enrolled) {
        return <NotEnrolled />;
    }

    if (!user) {
        return (
            <div className="min-h-screen flex items-center justify-center">
                <div className="text-center max-w-xs">
                    <div className="font-display font-bold text-3xl tracking-tight mb-1">Aurora</div>
                    <p className="text-sm text-muted-foreground mb-8">Welcome to Aurora</p>
                    <a
                        href={`/auth/discord?return_to=${encodeURIComponent(window.location.pathname)}`}
                        className="inline-flex items-center justify-center w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
                    >
                        Sign in with Discord
                    </a>
                </div>
            </div>
        );
    }

    return (
        <Layout user={user} logout={logout}>
            <Routes>
                {/* Root redirect based on role */}
                <Route path="/" element={<Navigate to={user.role === "admin" ? "/admin" : "/dashboard"} replace />} />

                {/* Player routes */}
                <Route path="/dashboard" element={<PlayerDashboard userId={user.discordId} />} />
                <Route path="/leaderboards" element={<PlaceholderPage {...placeholders.leaderboards} />} />

                {/* Game routes (both roles) */}
                <Route path="/games" element={<GameLobby />} />
                <Route path="/:gameSlug/:roomId" element={<GameRoom userId={user.discordId} />} />

                {/* Admin routes */}
                {user.role === "admin" && (
                    <>
                        <Route path="/admin" element={<Dashboard />} />
                        <Route path="/admin/users" element={<Users />} />
                        <Route path="/admin/items" element={<Items />} />
                        <Route path="/admin/classes" element={<PlaceholderPage {...placeholders.classes} />} />
                        <Route path="/admin/quests" element={<PlaceholderPage {...placeholders.quests} />} />
                        <Route path="/admin/lootdrops" element={<PlaceholderPage {...placeholders.lootdrops} />} />
                        <Route path="/admin/moderation" element={<PlaceholderPage {...placeholders.moderation} />} />
                        <Route path="/admin/transactions" element={<PlaceholderPage {...placeholders.transactions} />} />
                        <Route path="/admin/settings" element={<Settings />} />
                    </>
                )}

                {/* Catch-all */}
                <Route path="*" element={<Navigate to="/" replace />} />
            </Routes>
        </Layout>
    );
}

export default function App() {
    return (
        <BrowserRouter>
            <AppRoutes />
        </BrowserRouter>
    );
}
  • Step 3: Verify the app compiles

Run: cd panel && bun run build Expected: Build succeeds (GameLobby, GameRoom, and PlayerDashboard don't exist yet — create stubs first if needed, or do this step after Task 9-11). If it fails, create minimal stub exports for the missing components:

// panel/src/games/GameLobby.tsx (stub)
export function GameLobby() { return <div>Game Lobby  coming soon</div>; }

// panel/src/games/GameRoom.tsx (stub)
export function GameRoom({ userId }: { userId: string }) { return <div>Game Room  coming soon</div>; }

// panel/src/pages/PlayerDashboard.tsx (stub)
export default function PlayerDashboard({ userId }: { userId: string }) { return <div>Player Dashboard  coming soon</div>; }
  • Step 4: Commit
git add panel/src/App.tsx panel/src/components/Layout.tsx panel/src/games/ panel/src/pages/PlayerDashboard.tsx
git commit -m "feat(panel): migrate to React Router, role-based layout and routing"

Task 9: Panel — useWebSocket Hook

Files:

  • Create: panel/src/lib/useWebSocket.ts

  • Step 1: Implement shared WebSocket hook

// panel/src/lib/useWebSocket.ts

import { useEffect, useRef, useCallback, useState } from "react";

type MessageHandler = (data: any) => void;

interface WebSocketState {
    connected: boolean;
    send: (data: unknown) => void;
    subscribe: (handler: MessageHandler) => () => void;
}

let globalWs: WebSocket | null = null;
let globalHandlers = new Set<MessageHandler>();
let globalConnected = false;
let reconnectTimer: Timer | null = null;
let reconnectAttempt = 0;

function getWsUrl(): string {
    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
    return `${protocol}//${window.location.host}/ws`;
}

function connect(): void {
    if (globalWs?.readyState === WebSocket.OPEN || globalWs?.readyState === WebSocket.CONNECTING) return;

    const ws = new WebSocket(getWsUrl());

    ws.onopen = () => {
        globalConnected = true;
        reconnectAttempt = 0;
        globalHandlers.forEach(h => h({ type: "__WS_CONNECTED" }));
    };

    ws.onmessage = (event) => {
        try {
            const data = JSON.parse(event.data);
            globalHandlers.forEach(h => h(data));
        } catch {
            // ignore parse errors
        }
    };

    ws.onclose = () => {
        globalConnected = false;
        globalWs = null;
        globalHandlers.forEach(h => h({ type: "__WS_DISCONNECTED" }));

        // Reconnect with exponential backoff (max 30s)
        const delay = Math.min(1000 * 2 ** reconnectAttempt, 30000);
        reconnectAttempt++;
        reconnectTimer = setTimeout(connect, delay);
    };

    ws.onerror = () => {
        ws.close();
    };

    globalWs = ws;
}

function disconnect(): void {
    if (reconnectTimer) {
        clearTimeout(reconnectTimer);
        reconnectTimer = null;
    }
    globalWs?.close();
    globalWs = null;
    globalConnected = false;
}

export function useWebSocket(): WebSocketState {
    const [connected, setConnected] = useState(globalConnected);
    const refCount = useRef(0);

    useEffect(() => {
        refCount.current++;
        if (refCount.current === 1 && !globalWs) {
            connect();
        }

        const handler: MessageHandler = (data) => {
            if (data.type === "__WS_CONNECTED") setConnected(true);
            if (data.type === "__WS_DISCONNECTED") setConnected(false);
        };
        globalHandlers.add(handler);

        return () => {
            globalHandlers.delete(handler);
            refCount.current--;
            if (refCount.current === 0) {
                disconnect();
            }
        };
    }, []);

    const send = useCallback((data: unknown) => {
        if (globalWs?.readyState === WebSocket.OPEN) {
            globalWs.send(JSON.stringify(data));
        }
    }, []);

    const subscribe = useCallback((handler: MessageHandler) => {
        globalHandlers.add(handler);
        return () => { globalHandlers.delete(handler); };
    }, []);

    return { connected, send, subscribe };
}
  • Step 2: Commit
git add panel/src/lib/useWebSocket.ts
git commit -m "feat(panel): add shared useWebSocket hook with reconnection"

Task 10: Panel — useGameRoom Hook

Files:

  • Create: panel/src/lib/useGameRoom.ts

  • Step 1: Implement per-room hook

// panel/src/lib/useGameRoom.ts

import { useEffect, useState, useCallback } from "react";
import { useWebSocket } from "./useWebSocket";

interface PlayerInfo {
    discordId: string;
    username: string;
}

interface GameRoomState {
    gameState: unknown;
    players: PlayerInfo[];
    spectators: PlayerInfo[];
    roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found";
    isSpectator: boolean;
    gameOver: { winner: string | null; reason: string } | null;
    error: string | null;
}

export function useGameRoom(roomId: string, userId: string) {
    const { send, subscribe, connected } = useWebSocket();
    const [state, setState] = useState<GameRoomState>({
        gameState: null,
        players: [],
        spectators: [],
        roomStatus: "connecting",
        isSpectator: false,
        gameOver: null,
        error: null,
    });

    useEffect(() => {
        if (!connected) return;

        // Join room — try as player first, server will tell us if we're spectating
        send({ type: "JOIN_ROOM", roomId, as: "player" });

        const unsubscribe = subscribe((msg: any) => {
            if (msg.roomId && msg.roomId !== roomId) return;

            switch (msg.type) {
                case "GAME_STATE":
                    setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" }));
                    break;

                case "GAME_STARTED":
                    setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" }));
                    break;

                case "GAME_UPDATE":
                    setState(prev => ({ ...prev, gameState: msg.state }));
                    break;

                case "PLAYER_JOINED":
                    setState(prev => {
                        if (msg.as === "spectator") {
                            const isMe = msg.player.discordId === userId;
                            return {
                                ...prev,
                                spectators: [...prev.spectators.filter(s => s.discordId !== msg.player.discordId), msg.player],
                                isSpectator: isMe ? true : prev.isSpectator,
                                roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
                            };
                        }
                        return {
                            ...prev,
                            players: [...prev.players.filter(p => p.discordId !== msg.player.discordId), msg.player],
                            roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
                        };
                    });
                    break;

                case "PLAYER_LEFT":
                    setState(prev => ({
                        ...prev,
                        players: prev.players.filter(p => p.discordId !== msg.playerId),
                        spectators: prev.spectators.filter(s => s.discordId !== msg.playerId),
                    }));
                    break;

                case "GAME_ENDED":
                    setState(prev => ({
                        ...prev,
                        roomStatus: "finished",
                        gameOver: { winner: msg.winner, reason: msg.reason },
                    }));
                    break;

                case "ERROR":
                    // If we tried to join as player and it failed, retry as spectator
                    if (msg.message === "Game already started" || msg.message === "Room is full") {
                        send({ type: "JOIN_ROOM", roomId, as: "spectator" });
                    } else if (msg.message === "Room not found") {
                        setState(prev => ({ ...prev, roomStatus: "not_found" }));
                    } else {
                        setState(prev => ({ ...prev, error: msg.message }));
                    }
                    break;

                case "ROOM_LIST_UPDATE":
                    // ignore in room context
                    break;
            }
        });

        return () => {
            send({ type: "LEAVE_ROOM", roomId });
            unsubscribe();
        };
    }, [roomId, connected, userId, send, subscribe]);

    const sendAction = useCallback((action: unknown) => {
        send({ type: "GAME_ACTION", roomId, action });
        setState(prev => ({ ...prev, error: null }));
    }, [roomId, send]);

    const leaveRoom = useCallback(() => {
        send({ type: "LEAVE_ROOM", roomId });
    }, [roomId, send]);

    return { ...state, sendAction, leaveRoom };
}
  • Step 2: Commit
git add panel/src/lib/useGameRoom.ts
git commit -m "feat(panel): add useGameRoom hook for per-room game state"

Task 11: Panel — Client-Side Game Registry & Chess UI

Files:

  • Create: panel/src/games/registry.ts

  • Create: panel/src/games/chess/index.ts

  • Create: panel/src/games/chess/ChessBoard.tsx

  • Step 1: Create client-side game UI registry

// panel/src/games/registry.ts

import type { ComponentType } from "react";

export interface GameUIProps {
    state: any;
    myPlayerId: string;
    isSpectator: boolean;
    onAction: (action: unknown) => void;
    players: { discordId: string; username: string }[];
}

export interface GameUIPlugin {
    slug: string;
    name: string;
    icon: string;
    component: ComponentType<GameUIProps>;
}

const plugins = new Map<string, GameUIPlugin>();

export const gameUIRegistry = {
    register(plugin: GameUIPlugin) {
        plugins.set(plugin.slug, plugin);
    },
    get(slug: string): GameUIPlugin | undefined {
        return plugins.get(slug);
    },
    list(): GameUIPlugin[] {
        return Array.from(plugins.values());
    },
};
  • Step 2: Create ChessBoard component
// panel/src/games/chess/ChessBoard.tsx

import { useState } from "react";
import type { GameUIProps } from "../registry";

interface Piece {
    type: string;
    color: "white" | "black";
}

interface ChessState {
    board: (Piece | null)[][];
    currentTurn: "white" | "black";
    players: { white: string; black: string };
    moveHistory: string[];
    status: string;
    winner: string | null;
}

const PIECE_SYMBOLS: Record<string, Record<string, string>> = {
    white: { king: "♔", queen: "♕", rook: "♖", bishop: "♗", knight: "♘", pawn: "♙" },
    black: { king: "♚", queen: "♛", rook: "♜", bishop: "♝", knight: "♞", pawn: "♟" },
};

export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
    const chess = state as ChessState;
    const [selected, setSelected] = useState<[number, number] | null>(null);

    if (!chess?.board) {
        return <div className="text-text-tertiary text-sm">Waiting for game to start...</div>;
    }

    const myColor = chess.players.white === myPlayerId ? "white" : chess.players.black === myPlayerId ? "black" : null;
    const isMyTurn = myColor === chess.currentTurn && !isSpectator;

    function handleSquareClick(row: number, col: number) {
        if (isSpectator || !isMyTurn) return;

        if (selected) {
            // Attempt move
            onAction({ type: "move", from: selected, to: [row, col] });
            setSelected(null);
        } else {
            // Select piece
            const piece = chess.board[row][col];
            if (piece && piece.color === myColor) {
                setSelected([row, col]);
            }
        }
    }

    function handleForfeit() {
        onAction({ type: "forfeit" });
    }

    const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
    const opponent = players.find(p => p.discordId === opponentId);
    const me = players.find(p => p.discordId === myPlayerId);

    return (
        <div className="flex gap-4">
            {/* Board */}
            <div>
                {/* Opponent label */}
                <div className="flex items-center gap-2 mb-2">
                    <div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
                        {opponent?.username?.[0]?.toUpperCase() ?? "?"}
                    </div>
                    <span className="text-sm font-medium">{opponent?.username ?? "Opponent"}</span>
                    <span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
                </div>

                {/* Board grid */}
                <div className="inline-grid grid-cols-8 border-2 border-border rounded overflow-hidden">
                    {chess.board.map((row, r) =>
                        row.map((piece, c) => {
                            const isLight = (r + c) % 2 === 0;
                            const isSelected = selected?.[0] === r && selected?.[1] === c;
                            return (
                                <button
                                    key={`${r}-${c}`}
                                    onClick={() => handleSquareClick(r, c)}
                                    className={`w-12 h-12 flex items-center justify-center text-2xl transition-colors ${
                                        isSelected
                                            ? "bg-primary/40"
                                            : isLight
                                                ? "bg-raised"
                                                : "bg-surface"
                                    } ${isMyTurn ? "cursor-pointer hover:bg-primary/20" : "cursor-default"}`}
                                >
                                    {piece ? PIECE_SYMBOLS[piece.color][piece.type] : ""}
                                </button>
                            );
                        })
                    )}
                </div>

                {/* Player label */}
                <div className="flex items-center gap-2 mt-2">
                    <div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium border-2 border-primary">
                        {me?.username?.[0]?.toUpperCase() ?? "?"}
                    </div>
                    <span className="text-sm font-medium">{me?.username ?? "You"}</span>
                    <span className="text-xs text-text-tertiary">· {myColor ?? "Spectator"}</span>
                    {isMyTurn && (
                        <span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded">Your turn</span>
                    )}
                </div>
            </div>

            {/* Side panel */}
            <div className="flex flex-col gap-3 min-w-[180px]">
                {/* Move history */}
                <div className="bg-card rounded-lg border border-border">
                    <div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
                    <div className="px-4 py-2 max-h-64 overflow-y-auto">
                        {chess.moveHistory.length === 0 ? (
                            <div className="text-xs text-text-disabled">No moves yet</div>
                        ) : (
                            <div className="text-xs text-text-tertiary font-mono leading-6">
                                {chess.moveHistory.map((move, i) => (
                                    <span key={i}>
                                        {i % 2 === 0 && <span className="text-text-disabled">{Math.floor(i / 2) + 1}. </span>}
                                        {move}{" "}
                                    </span>
                                ))}
                            </div>
                        )}
                    </div>
                </div>

                {/* Forfeit button */}
                {!isSpectator && chess.status === "playing" && (
                    <button
                        onClick={handleForfeit}
                        className="rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
                    >
                        Forfeit
                    </button>
                )}
            </div>
        </div>
    );
}
  • Step 3: Register chess UI plugin
// panel/src/games/chess/index.ts

import { gameUIRegistry } from "../registry";
import { ChessBoard } from "./ChessBoard";

gameUIRegistry.register({
    slug: "chess",
    name: "Chess",
    icon: "♟",
    component: ChessBoard,
});
  • Step 4: Commit
git add panel/src/games/registry.ts panel/src/games/chess/
git commit -m "feat(panel): add game UI registry and chess board component"

Task 12: Panel — GameLobby & GameRoom Pages

Files:

  • Modify: panel/src/games/GameLobby.tsx

  • Modify: panel/src/games/GameRoom.tsx

  • Step 1: Implement GameLobby

// panel/src/games/GameLobby.tsx

import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useWebSocket } from "../lib/useWebSocket";
import { gameUIRegistry } from "./registry";
// Ensure chess plugin is registered
import "./chess";

interface RoomSummary {
    id: string;
    gameSlug: string;
    gameName: string;
    host: string;
    playerCount: number;
    maxPlayers: number;
    spectatorCount: number;
    status: "waiting" | "playing" | "finished";
}

export function GameLobby() {
    const { send, subscribe, connected } = useWebSocket();
    const navigate = useNavigate();
    const [rooms, setRooms] = useState<RoomSummary[]>([]);
    const [filter, setFilter] = useState<string | null>(null);
    const [showCreate, setShowCreate] = useState(false);

    const gameTypes = gameUIRegistry.list();

    useEffect(() => {
        if (!connected) return;

        const unsubscribe = subscribe((msg: any) => {
            if (msg.type === "ROOM_LIST_UPDATE") {
                setRooms(msg.rooms);
            }
            if (msg.type === "ROOM_CREATED") {
                navigate(`/${msg.gameSlug}/${msg.roomId}`);
            }
        });

        return unsubscribe;
    }, [connected, subscribe, navigate]);

    const filteredRooms = filter ? rooms.filter(r => r.gameSlug === filter) : rooms;
    const activeRooms = filteredRooms.filter(r => r.status !== "finished");

    function createRoom(gameSlug: string) {
        send({ type: "CREATE_ROOM", gameType: gameSlug });
        setShowCreate(false);
    }

    return (
        <div>
            {/* Header */}
            <div className="flex items-center justify-between mb-6">
                <div>
                    <h1 className="font-display text-lg font-semibold">Games</h1>
                    <p className="text-sm text-text-tertiary">Browse and create game rooms</p>
                </div>
                <button
                    onClick={() => setShowCreate(true)}
                    className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
                >
                    + Create Room
                </button>
            </div>

            {/* Filter tabs */}
            <div className="flex gap-2 mb-4">
                <button
                    onClick={() => setFilter(null)}
                    className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
                        filter === null ? "bg-primary/15 text-primary" : "bg-card text-text-tertiary hover:text-foreground"
                    }`}
                >
                    All Games
                </button>
                {gameTypes.map(g => (
                    <button
                        key={g.slug}
                        onClick={() => setFilter(g.slug)}
                        className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
                            filter === g.slug ? "bg-primary/15 text-primary" : "bg-card text-text-tertiary hover:text-foreground"
                        }`}
                    >
                        {g.icon} {g.name}
                    </button>
                ))}
            </div>

            {/* Room list */}
            <div className="bg-card rounded-lg border border-border">
                <div className="flex items-center gap-2 px-5 py-3 border-b border-border">
                    <span className="text-sm font-semibold">Active Rooms</span>
                    <span className="text-xs text-text-disabled">({activeRooms.length})</span>
                </div>
                {activeRooms.length === 0 ? (
                    <div className="px-5 py-8 text-center text-sm text-text-tertiary">
                        No active rooms. Create one to get started!
                    </div>
                ) : (
                    <div className="divide-y divide-border">
                        {activeRooms.map(room => {
                            const plugin = gameUIRegistry.get(room.gameSlug);
                            return (
                                <div key={room.id} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
                                    <div className="flex items-center gap-3">
                                        <span className="text-lg">{plugin?.icon ?? "🎮"}</span>
                                        <div>
                                            <div className="text-sm font-medium">{room.gameName}</div>
                                            <div className="flex items-center gap-2 text-xs text-text-tertiary">
                                                <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
                                                    room.status === "waiting"
                                                        ? "bg-warning/15 text-warning"
                                                        : "bg-success/15 text-success"
                                                }`}>
                                                    {room.status === "waiting" ? "Waiting" : "Playing"}
                                                </span>
                                                <span>{room.playerCount}/{room.maxPlayers} players</span>
                                                {room.spectatorCount > 0 && <span>· 👁 {room.spectatorCount}</span>}
                                            </div>
                                        </div>
                                    </div>
                                    <button
                                        onClick={() => navigate(`/${room.gameSlug}/${room.id}`)}
                                        className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
                                            room.status === "waiting"
                                                ? "bg-primary text-primary-foreground hover:bg-primary/90"
                                                : "bg-card border border-border text-text-tertiary hover:text-foreground"
                                        }`}
                                    >
                                        {room.status === "waiting" ? "Join" : "Spectate"}
                                    </button>
                                </div>
                            );
                        })}
                    </div>
                )}
            </div>

            {/* Create room dialog */}
            {showCreate && (
                <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
                    <div className="bg-card border border-border rounded-lg p-6 w-full max-w-sm" onClick={e => e.stopPropagation()}>
                        <h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
                        <div className="space-y-2">
                            {gameTypes.map(g => (
                                <button
                                    key={g.slug}
                                    onClick={() => createRoom(g.slug)}
                                    className="w-full flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm font-medium hover:bg-raised/40 transition-colors"
                                >
                                    <span className="text-lg">{g.icon}</span>
                                    <span>{g.name}</span>
                                </button>
                            ))}
                        </div>
                        <button
                            onClick={() => setShowCreate(false)}
                            className="mt-4 w-full text-center text-sm text-text-tertiary hover:text-foreground transition-colors"
                        >
                            Cancel
                        </button>
                    </div>
                </div>
            )}
        </div>
    );
}
  • Step 2: Implement GameRoom
// panel/src/games/GameRoom.tsx

import { useParams, useNavigate } from "react-router-dom";
import { useGameRoom } from "../lib/useGameRoom";
import { gameUIRegistry } from "./registry";
import { Loader2 } from "lucide-react";
import "./chess";

export function GameRoom({ userId }: { userId: string }) {
    const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>();
    const navigate = useNavigate();
    const {
        gameState, players, spectators, roomStatus,
        isSpectator, gameOver, error, sendAction, leaveRoom,
    } = useGameRoom(roomId!, userId);

    const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;

    if (!plugin) {
        return (
            <div className="text-center py-16">
                <div className="text-lg font-display font-semibold mb-2">Unknown Game</div>
                <p className="text-sm text-text-tertiary mb-4">The game type "{gameSlug}" doesn't exist.</p>
                <button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
                    Back to Games
                </button>
            </div>
        );
    }

    if (roomStatus === "not_found") {
        return (
            <div className="text-center py-16">
                <div className="text-lg font-display font-semibold mb-2">Room Not Found</div>
                <p className="text-sm text-text-tertiary mb-4">This room no longer exists or has expired.</p>
                <button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
                    Back to Games
                </button>
            </div>
        );
    }

    if (roomStatus === "connecting") {
        return (
            <div className="flex items-center justify-center py-16">
                <Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
            </div>
        );
    }

    const GameComponent = plugin.component;

    return (
        <div>
            {/* Room header */}
            <div className="flex items-center justify-between mb-6">
                <div className="flex items-center gap-3">
                    <span className="text-xl">{plugin.icon}</span>
                    <div>
                        <h1 className="font-display text-base font-semibold">{plugin.name}</h1>
                        <div className="flex items-center gap-2 text-xs text-text-tertiary">
                            <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
                                roomStatus === "waiting" ? "bg-warning/15 text-warning"
                                    : roomStatus === "playing" ? "bg-success/15 text-success"
                                    : "bg-card text-text-tertiary"
                            }`}>
                                {roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"}
                            </span>
                            {isSpectator && <span className="text-text-disabled">Spectating</span>}
                            <span>👁 {spectators.length}</span>
                        </div>
                    </div>
                </div>
                <button
                    onClick={() => { leaveRoom(); navigate("/games"); }}
                    className="rounded-md px-3 py-1.5 text-sm font-medium bg-card border border-border text-text-tertiary hover:text-foreground transition-colors"
                >
                    Leave
                </button>
            </div>

            {/* Error banner */}
            {error && (
                <div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
                    {error}
                </div>
            )}

            {/* Game over banner */}
            {gameOver && (
                <div className="mb-4 rounded-lg border border-primary/30 bg-primary/10 px-4 py-3">
                    <div className="text-sm font-semibold text-primary">
                        {gameOver.winner
                            ? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
                            : "Draw!"}
                    </div>
                    <div className="text-xs text-text-tertiary mt-1">Reason: {gameOver.reason}</div>
                </div>
            )}

            {/* Waiting state */}
            {roomStatus === "waiting" && (
                <div className="bg-card rounded-lg border border-border p-8 text-center">
                    <div className="text-sm text-text-tertiary mb-2">
                        Waiting for players ({players.length}/{plugin.name === "Chess" ? 2 : "?"})
                    </div>
                    <div className="text-xs text-text-disabled">
                        Share this URL to invite: <span className="font-mono bg-surface px-2 py-0.5 rounded select-all">{window.location.href}</span>
                    </div>
                </div>
            )}

            {/* Game component */}
            {(roomStatus === "playing" || roomStatus === "finished") && gameState && (
                <GameComponent
                    state={gameState}
                    myPlayerId={userId}
                    isSpectator={isSpectator}
                    onAction={sendAction}
                    players={players}
                />
            )}
        </div>
    );
}
  • Step 3: Verify the full app builds

Run: cd panel && bun run build Expected: Build succeeds

  • Step 4: Commit
git add panel/src/games/GameLobby.tsx panel/src/games/GameRoom.tsx
git commit -m "feat(panel): implement GameLobby and GameRoom pages"

Task 13: Panel — Player Dashboard

Files:

  • Modify: panel/src/pages/PlayerDashboard.tsx

  • Step 1: Implement PlayerDashboard

// panel/src/pages/PlayerDashboard.tsx

import { useState, useEffect } from "react";
import { get } from "../lib/api";
import { Loader2 } from "lucide-react";

interface UserData {
    id: string;
    username: string;
    level: number;
    xp: string;
    balance: string;
    className: string | null;
}

interface InventoryItem {
    itemId: string;
    name: string;
    quantity: number;
    rarity: string;
}

export default function PlayerDashboard({ userId }: { userId: string }) {
    const [user, setUser] = useState<UserData | null>(null);
    const [inventory, setInventory] = useState<InventoryItem[]>([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        async function load() {
            try {
                const [userData, invData] = await Promise.all([
                    get<UserData>(`/api/users/${userId}`),
                    get<{ items: InventoryItem[] }>(`/api/users/${userId}/inventory`).catch(() => ({ items: [] })),
                ]);
                setUser(userData);
                setInventory(invData.items ?? []);
            } catch (e) {
                setError("Failed to load profile");
            } finally {
                setLoading(false);
            }
        }
        load();
    }, [userId]);

    if (loading) {
        return (
            <div className="flex items-center justify-center py-16">
                <Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
            </div>
        );
    }

    if (error || !user) {
        return (
            <div className="text-center py-16 text-sm text-text-tertiary">
                {error ?? "Could not load your profile."}
            </div>
        );
    }

    return (
        <div>
            <h1 className="font-display text-lg font-semibold mb-6">Dashboard</h1>

            {/* Stat cards */}
            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
                <StatCard label="Level" value={String(user.level)} accent="primary" subtitle={user.className ?? undefined} />
                <StatCard label="Gold" value={formatNumber(user.balance)} accent="gold" />
                <StatCard label="XP" value={formatNumber(user.xp)} accent="info" />
                <StatCard label="Items" value={String(inventory.length)} accent="success" />
            </div>

            {/* Inventory preview */}
            <div className="bg-card rounded-lg border border-border">
                <div className="flex items-center gap-2 px-5 py-3 border-b border-border">
                    <span className="text-sm font-semibold">Inventory</span>
                    <span className="text-xs text-text-disabled">({inventory.length})</span>
                </div>
                {inventory.length === 0 ? (
                    <div className="px-5 py-6 text-center text-sm text-text-tertiary">No items yet</div>
                ) : (
                    <div className="divide-y divide-border">
                        {inventory.slice(0, 10).map((item, i) => (
                            <div key={i} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
                                <div className="text-sm font-medium">{item.name}</div>
                                <div className="flex items-center gap-2">
                                    <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${rarityColor(item.rarity)}`}>
                                        {item.rarity}
                                    </span>
                                    {item.quantity > 1 && (
                                        <span className="text-xs text-text-tertiary font-mono">x{item.quantity}</span>
                                    )}
                                </div>
                            </div>
                        ))}
                        {inventory.length > 10 && (
                            <div className="px-5 py-2 text-xs text-text-disabled text-center">
                                and {inventory.length - 10} more
                            </div>
                        )}
                    </div>
                )}
            </div>
        </div>
    );
}

function StatCard({ label, value, accent, subtitle }: { label: string; value: string; accent: string; subtitle?: string }) {
    const borderColor: Record<string, string> = {
        primary: "border-l-primary",
        gold: "border-l-gold",
        info: "border-l-info",
        success: "border-l-success",
    };
    return (
        <div className={`bg-gradient-to-br from-card to-surface border border-border rounded-lg p-5 border-l-4 ${borderColor[accent] ?? ""}`}>
            <div className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">{label}</div>
            <div className="text-2xl font-bold font-display tracking-tight mt-1">{value}</div>
            {subtitle && <div className="text-sm text-text-tertiary mt-0.5">{subtitle}</div>}
        </div>
    );
}

function formatNumber(val: string): string {
    const n = Number(val);
    if (isNaN(n)) return val;
    if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
    if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
    return n.toLocaleString();
}

function rarityColor(rarity: string): string {
    switch (rarity?.toUpperCase()) {
        case "C": return "bg-gray-500/20 text-gray-400";
        case "R": return "bg-blue-500/20 text-blue-400";
        case "SR": return "bg-purple-500/20 text-purple-400";
        case "SSR": return "bg-amber-500/20 text-amber-400";
        default: return "bg-gray-500/20 text-gray-400";
    }
}
  • Step 2: Commit
git add panel/src/pages/PlayerDashboard.tsx
git commit -m "feat(panel): implement player dashboard with stats and inventory"

Task 14: Register Chess Plugin on Server Startup

Files:

  • Modify: bot/index.ts (or wherever the server starts)

  • Step 1: Find and read the entry point

Run: head -30 bot/index.ts to understand how the server is started.

  • Step 2: Register chess plugin before server starts

Add at the top of the entry point file, before createWebServer is called:

import { gameRegistry } from "@shared/games/registry";
import { chessPlugin } from "@shared/games/chess/plugin";

gameRegistry.register(chessPlugin);
  • Step 3: Verify the server starts cleanly

Run: bun --watch bot/index.ts — confirm no import errors. Ctrl+C to stop.

  • Step 4: Commit
git add bot/index.ts
git commit -m "feat(games): register chess plugin on server startup"

Task 15: End-to-End Smoke Test

  • Step 1: Build the panel

Run: cd panel && bun run build Expected: Build succeeds with no errors

  • Step 2: Run all existing tests

Run: bun test Expected: All existing tests still pass (no regressions)

  • Step 3: Run game-specific tests

Run: bun test shared/games/ api/src/games/ Expected: All game tests pass

  • Step 4: Manual smoke test checklist

Start the server (bun --watch bot/index.ts) and open the panel:

  1. Navigate to /auth/discord — OAuth flow should work
  2. As a player: should land on /dashboard with stats
  3. Navigate to /games — should see empty lobby
  4. Click "Create Room" → Chess — should navigate to /chess/<uuid>
  5. Copy the URL and open in a second browser/incognito — should be able to join as player 2
  6. Game should auto-start when both players join
  7. Make moves — board should update in real-time for both players
  8. Open a third browser — should be able to spectate
  9. Forfeit — game should end with winner displayed
  10. As an admin: should land on /admin with existing dashboard
  11. Admin should see "Games" in sidebar and be able to access /games
  • Step 5: Final commit if any fixes were needed
git add -A
git commit -m "fix: address smoke test issues"