diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 22d89d0..03ac1cb 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "frontend-tmp",
+ "name": "commander-ai-lab-frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "frontend-tmp",
+ "name": "commander-ai-lab-frontend",
"version": "0.0.0",
"dependencies": {
"clsx": "^2.1.1",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 46a5992..07acb0f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,121 +1,17 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from './assets/vite.svg'
-import heroImg from './assets/hero.png'
-import './App.css'
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import LobbyPage from './pages/LobbyPage';
+import RoomPage from './pages/RoomPage';
+import GamePage from './pages/GamePage';
function App() {
- const [count, setCount] = useState(0)
-
return (
- <>
-
-
-
-
Get started
-
- Edit src/App.tsx and save to test HMR
-
-
-
-
-
-
-
-
-
-
-
Documentation
-
Your questions, answered
-
-
-
-
-
Connect with us
-
Join the Vite community
-
-
-
-
-
-
- >
- )
+
+
+ } />
+ } />
+ } />
+
+
+ );
}
-
-export default App
+export default App;
diff --git a/frontend/src/components/Board.tsx b/frontend/src/components/Board.tsx
new file mode 100644
index 0000000..cd5ca2c
--- /dev/null
+++ b/frontend/src/components/Board.tsx
@@ -0,0 +1,54 @@
+import type { GameState } from '../types/game';
+import { SEAT_COLOURS } from '../constants';
+import PlayerZone from './PlayerZone';
+import CardChip from './CardChip';
+
+interface BoardProps {
+ gameState: GameState;
+ thinkingSeats: Set;
+}
+
+export default function Board({ gameState, thinkingSeats }: BoardProps) {
+ const { players, active_seat, stack } = gameState;
+
+ const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
+
+ const renderZone = (seat: number, className: string) => {
+ const player = getPlayer(seat);
+ if (!player) return ;
+ return (
+
+ );
+ };
+
+ return (
+
+ {/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
+ {renderZone(1, 'row-start-1 col-start-1')}
+
+
Stack
+
+ {stack.map((card, i) => (
+
+ ))}
+
+
+ {renderZone(2, 'row-start-1 col-start-3')}
+
+ {/* Row 2: empty side cells (stack spans all 3 rows in center) */}
+
+
+
+ {/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
+ {renderZone(0, 'row-start-3 col-start-1')}
+ {renderZone(3, 'row-start-3 col-start-3')}
+
+ );
+}
diff --git a/frontend/src/components/CardChip.tsx b/frontend/src/components/CardChip.tsx
new file mode 100644
index 0000000..73dbac1
--- /dev/null
+++ b/frontend/src/components/CardChip.tsx
@@ -0,0 +1,20 @@
+import type { Card } from '../types/game';
+
+interface CardChipProps {
+ card: Card;
+ onClick?: () => void;
+}
+
+export default function CardChip({ card, onClick }: CardChipProps) {
+ return (
+
+ {card.name}
+ {card.is_commander && ★}
+
+ );
+}
diff --git a/frontend/src/components/GameOverOverlay.tsx b/frontend/src/components/GameOverOverlay.tsx
new file mode 100644
index 0000000..63fdb6b
--- /dev/null
+++ b/frontend/src/components/GameOverOverlay.tsx
@@ -0,0 +1,40 @@
+import type { Player } from '../types/game';
+import { SEAT_COLOURS } from '../constants';
+
+interface GameOverOverlayProps {
+ show: boolean;
+ winner: Player | null;
+ reason: string;
+ players: Player[];
+}
+
+export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
+ if (!show) return null;
+
+ return (
+
+
+ {winner && (
+
+ {winner.name} Wins!
+
+ )}
+
{reason}
+
+ {players.map((p) => (
+ -
+ P{p.seat}{' '}
+ {p.name} — {p.life} life
+ {p.eliminated && (
+ [ELIM]
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/HandCard.tsx b/frontend/src/components/HandCard.tsx
new file mode 100644
index 0000000..291f020
--- /dev/null
+++ b/frontend/src/components/HandCard.tsx
@@ -0,0 +1,23 @@
+import type { Card } from '../types/game';
+
+interface HandCardProps {
+ card: Card;
+ index: number;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/MoveBar.tsx b/frontend/src/components/MoveBar.tsx
new file mode 100644
index 0000000..6eefcf8
--- /dev/null
+++ b/frontend/src/components/MoveBar.tsx
@@ -0,0 +1,54 @@
+import { useState } from 'react';
+import type { Card } from '../types/game';
+import HandCard from './HandCard';
+
+interface MoveBarProps {
+ hand: Card[];
+ humanSeat: number;
+ onPass: () => void;
+ onConcede: () => void;
+ onPlayCard: (index: number) => void;
+ status: string;
+}
+
+export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
+ const [selectedIndex, setSelectedIndex] = useState(null);
+
+ const handleCardClick = (index: number) => {
+ if (selectedIndex === index) {
+ onPlayCard(index);
+ setSelectedIndex(null);
+ } else {
+ setSelectedIndex(index);
+ }
+ };
+
+ return (
+
+
+
+ {hand.map((card, i) => (
+ handleCardClick(i)}
+ />
+ ))}
+ {status}
+
+ );
+}
diff --git a/frontend/src/components/NarrationPanel.tsx b/frontend/src/components/NarrationPanel.tsx
new file mode 100644
index 0000000..7606037
--- /dev/null
+++ b/frontend/src/components/NarrationPanel.tsx
@@ -0,0 +1,68 @@
+import { useRef, useEffect, useState } from 'react';
+import type { NarrationLine } from '../types/game';
+import { SEAT_COLOURS } from '../constants';
+
+interface NarrationPanelProps {
+ lines: NarrationLine[];
+ thinkingSeats: Set;
+}
+
+export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
+ const containerRef = useRef(null);
+ const [dots, setDots] = useState('.');
+
+ // Auto-scroll on new lines
+ useEffect(() => {
+ const el = containerRef.current;
+ if (el) el.scrollTop = el.scrollHeight;
+ }, [lines.length]);
+
+ // Cycling dots for thinking animation
+ useEffect(() => {
+ if (thinkingSeats.size === 0) return;
+ const interval = setInterval(() => {
+ setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
+ }, 500);
+ return () => clearInterval(interval);
+ }, [thinkingSeats.size]);
+
+ const displayed = lines.slice(-120);
+
+ return (
+
+ {displayed.map((line, i) => {
+ let colorClass = 'text-[#e8e8e8]';
+ let style: React.CSSProperties | undefined;
+
+ if (line.isSystem) {
+ colorClass = 'text-[#556]';
+ } else if (line.seat !== undefined) {
+ colorClass = '';
+ style = { color: SEAT_COLOURS[line.seat] };
+ }
+
+ return (
+
+ {line.text}
+
+ );
+ })}
+ {thinkingSeats.size > 0 &&
+ Array.from(thinkingSeats).map((seat) => (
+
+ P{seat} thinking{dots}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/PhaseBar.tsx b/frontend/src/components/PhaseBar.tsx
new file mode 100644
index 0000000..a2be38f
--- /dev/null
+++ b/frontend/src/components/PhaseBar.tsx
@@ -0,0 +1,37 @@
+import type { Phase, Player } from '../types/game';
+import { PHASES, SEAT_COLOURS } from '../constants';
+
+interface PhaseBarProps {
+ phase: Phase;
+ activeSeat: number;
+ players: Player[];
+}
+
+export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
+ const activePlayer = players.find((p) => p.seat === activeSeat);
+
+ return (
+
+ {PHASES.map((p) => (
+
+ ))}
+ {activePlayer && (
+
+ P{activeSeat}: {activePlayer.name}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/PlayerZone.tsx b/frontend/src/components/PlayerZone.tsx
new file mode 100644
index 0000000..b74b363
--- /dev/null
+++ b/frontend/src/components/PlayerZone.tsx
@@ -0,0 +1,40 @@
+import type { Player } from '../types/game';
+import CardChip from './CardChip';
+
+interface PlayerZoneProps {
+ player: Player;
+ isActive: boolean;
+ isThinking: boolean;
+ seatColour: string;
+}
+
+function lifeColor(life: number): string {
+ if (life >= 30) return 'text-life-high';
+ if (life >= 15) return 'text-life-med';
+ return 'text-life-low';
+}
+
+export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
+ return (
+
+
+
+ P{player.seat}: {player.name}
+
+
+ {player.life}
+
+
+
+ {player.battlefield.map((card, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts
new file mode 100644
index 0000000..40568a8
--- /dev/null
+++ b/frontend/src/hooks/useGameSocket.ts
@@ -0,0 +1,132 @@
+import { useReducer, useRef, useEffect, useCallback } from 'react';
+import { gameReducer, initialState } from '../store/gameReducer';
+import type { WsIncoming, NarrationLine } from '../types/game';
+
+export function useGameSocket(gameId: string) {
+ const [state, dispatch] = useReducer(gameReducer, initialState);
+ const wsRef = useRef(null);
+ const reconnectTimeout = useRef | null>(null);
+ const heartbeatInterval = useRef | null>(null);
+ const intentionalClose = useRef(false);
+
+ const connect = useCallback(() => {
+ const url = `ws://${window.location.host}/ws/game/${gameId}`;
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ dispatch({ type: 'SET_STATUS', status: 'Connected' });
+ };
+
+ ws.onmessage = (event) => {
+ const msg: WsIncoming = JSON.parse(event.data);
+
+ switch (msg.type) {
+ case 'state':
+ dispatch({ type: 'SET_STATE', payload: msg.payload });
+ break;
+
+ case 'hand':
+ dispatch({ type: 'SET_HAND', cards: msg.cards });
+ break;
+
+ case 'event': {
+ const line: NarrationLine = {
+ text: msg.text,
+ seat: msg.seat,
+ isSystem: false,
+ timestamp: Date.now(),
+ };
+ dispatch({ type: 'ADD_NARRATION', line });
+ break;
+ }
+
+ case 'phase': {
+ const line: NarrationLine = {
+ text: `Phase: ${msg.phase} (P${msg.active_seat})`,
+ isSystem: true,
+ timestamp: Date.now(),
+ };
+ dispatch({ type: 'ADD_NARRATION', line });
+ break;
+ }
+
+ case 'thinking':
+ dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
+ break;
+
+ case 'game_over':
+ dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
+ break;
+
+ case 'pong':
+ break;
+
+ case 'error':
+ dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
+ break;
+ }
+ };
+
+ ws.onclose = () => {
+ if (!intentionalClose.current) {
+ dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
+ reconnectTimeout.current = setTimeout(() => {
+ connect();
+ }, 3000);
+ }
+ };
+
+ ws.onerror = () => {
+ dispatch({ type: 'SET_STATUS', status: 'Connection error' });
+ };
+ }, [gameId]);
+
+ // Connect on mount
+ useEffect(() => {
+ intentionalClose.current = false;
+ connect();
+
+ return () => {
+ intentionalClose.current = true;
+ if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
+ wsRef.current?.close();
+ };
+ }, [connect]);
+
+ // Heartbeat ping every 15s
+ useEffect(() => {
+ heartbeatInterval.current = setInterval(() => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'ping' }));
+ }
+ }, 15000);
+
+ return () => {
+ if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
+ };
+ }, []);
+
+ const sendMove = useCallback((move: string, data?: Record) => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
+ }
+ }, []);
+
+ const sendConcede = useCallback(() => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'concede' }));
+ }
+ }, []);
+
+ return {
+ gameState: state.gameState,
+ narration: state.narration,
+ thinking: state.thinkingSeats,
+ hand: state.hand,
+ gameOver: state.gameOver,
+ status: state.status,
+ sendMove,
+ sendConcede,
+ };
+}
diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx
new file mode 100644
index 0000000..c241ac2
--- /dev/null
+++ b/frontend/src/pages/GamePage.tsx
@@ -0,0 +1,53 @@
+import { useParams } from 'react-router-dom';
+import { useGameSocket } from '../hooks/useGameSocket';
+import PhaseBar from '../components/PhaseBar';
+import Board from '../components/Board';
+import NarrationPanel from '../components/NarrationPanel';
+import MoveBar from '../components/MoveBar';
+import GameOverOverlay from '../components/GameOverOverlay';
+
+export default function GamePage() {
+ const { gameId } = useParams<{ gameId: string }>();
+ const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
+ useGameSocket(gameId!);
+
+ if (!gameState) {
+ return (
+
+ Connecting…
+
+ );
+ }
+
+ const winnerPlayer = gameOver
+ ? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
+ : null;
+
+ return (
+
+
+
+
+
+
+
sendMove('pass')}
+ onConcede={sendConcede}
+ onPlayCard={(index) => sendMove('play_card', { index })}
+ status={status}
+ />
+
+
+ );
+}
diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx
new file mode 100644
index 0000000..1cb2ab7
--- /dev/null
+++ b/frontend/src/pages/LobbyPage.tsx
@@ -0,0 +1,7 @@
+export default function LobbyPage() {
+ return (
+
+
Lobby — coming soon
+
+ );
+}
diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx
new file mode 100644
index 0000000..18cfd27
--- /dev/null
+++ b/frontend/src/pages/RoomPage.tsx
@@ -0,0 +1,7 @@
+export default function RoomPage() {
+ return (
+
+
Room — coming soon
+
+ );
+}
diff --git a/frontend/src/store/gameReducer.ts b/frontend/src/store/gameReducer.ts
new file mode 100644
index 0000000..d3a80c7
--- /dev/null
+++ b/frontend/src/store/gameReducer.ts
@@ -0,0 +1,61 @@
+import type { GameState, Card, NarrationLine } from '../types/game';
+
+export type GameAction =
+ | { type: 'SET_STATE'; payload: GameState }
+ | { type: 'SET_HAND'; cards: Card[] }
+ | { type: 'ADD_NARRATION'; line: NarrationLine }
+ | { type: 'SET_THINKING'; seat: number; active: boolean }
+ | { type: 'GAME_OVER'; winner: number; reason: string }
+ | { type: 'SET_STATUS'; status: string };
+
+export interface ReducerState {
+ gameState: GameState | null;
+ hand: Card[];
+ narration: NarrationLine[];
+ thinkingSeats: Set;
+ gameOver: { winner: number; reason: string } | null;
+ status: string;
+}
+
+export const initialState: ReducerState = {
+ gameState: null,
+ hand: [],
+ narration: [],
+ thinkingSeats: new Set(),
+ gameOver: null,
+ status: '',
+};
+
+export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
+ switch (action.type) {
+ case 'SET_STATE':
+ return { ...state, gameState: action.payload };
+
+ case 'SET_HAND':
+ return { ...state, hand: action.cards };
+
+ case 'ADD_NARRATION': {
+ const narration = [...state.narration, action.line];
+ return { ...state, narration: narration.slice(-120) };
+ }
+
+ case 'SET_THINKING': {
+ const thinkingSeats = new Set(state.thinkingSeats);
+ if (action.active) {
+ thinkingSeats.add(action.seat);
+ } else {
+ thinkingSeats.delete(action.seat);
+ }
+ return { ...state, thinkingSeats };
+ }
+
+ case 'GAME_OVER':
+ return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
+
+ case 'SET_STATUS':
+ return { ...state, status: action.status };
+
+ default:
+ return state;
+ }
+}
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-