This file provides context to help AI assistants (like Claude Code) work effectively on this codebase.
Coup Online is a real-time multiplayer web adaptation of the card game Coup (2-6 players). Players bluff, challenge, and block to eliminate opponents' influences. The project is a full-stack TypeScript application with a server-authoritative architecture.
Stack: Next.js 15 (App Router) + Express + Socket.io + Zustand + Tailwind CSS + Vitest
# Install dependencies
npm install
# Start the development server (Express + Next.js + Socket.io, all in one process)
npm run dev
# Run tests
npm test
# Run tests in watch mode
npm run test:watch
# Production build
npm run build
# Start production server
npm startThe dev server runs at http://localhost:3000. To test multiplayer, open multiple browser tabs.
All game logic runs on the server. Clients send intents (game:action, game:challenge, etc.) and receive filtered state. The client never computes game outcomes. This prevents cheating and ensures consistency.
The src/engine/ directory contains the core game rules with no I/O, no timers, no Socket.io. The ActionResolver is essentially a pure function: given the current game state and a player action, it returns a ResolverResult containing:
newPhase-- the next turn phase- Updated pending action/block/challenge/exchange/influence-loss state
sideEffects[]-- a list of effects to apply (give coins, reveal card, advance turn, set timer, log, etc.)
The GameEngine orchestrates: it calls the resolver, then applies side effects to the mutable Game object. This separation makes the rules easy to test without mocking timers or sockets.
The server holds full GameState (including the deck and all hidden cards). Before sending to a client, StateSerializer.serializeForPlayer() filters it into ClientGameState:
- Opponents' unrevealed cards become
{ character: null, revealed: false } - The deck array becomes just a count (
deckCount) - Exchange state is only sent to the player who is exchanging
The turn progresses through these phases (defined in TurnPhase enum):
AwaitingAction-- current player picks an actionAwaitingActionChallenge-- other players may challenge the claimAwaitingBlock-- eligible players may blockAwaitingBlockChallenge-- original actor may challenge the blockAwaitingInfluenceLoss-- a player must choose a card to revealAwaitingExchange-- Ambassador/Inquisitor player picks cards to keepAwaitingExamineDecision-- Inquisitor player decides to return or force-swap examined card (Reformation only)ActionResolved-- turn ends, advance to next playerGameOver-- winner determined
Not every turn visits every phase. Income resolves immediately. Coup skips to InfluenceLoss. The ActionResolver determines the path.
| File | Purpose |
|---|---|
src/shared/types.ts |
All TypeScript types: GameState, ClientGameState, enums, interfaces |
src/shared/constants.ts |
Game constants (costs, timers, player limits) and action definitions |
src/shared/protocol.ts |
Socket.io event type contracts (client-to-server and server-to-client) |
src/engine/GameEngine.ts |
Orchestrator: connects resolver results to game state mutations |
src/engine/ActionResolver.ts |
Pure state machine: all game rule logic and phase transitions |
src/engine/Game.ts |
Game model: players, deck, turn order, treasury, action log |
src/engine/Player.ts |
Player model: influences, coins, hasCharacter, revealInfluence |
src/engine/Deck.ts |
Card deck: shuffle (Fisher-Yates), draw, return, reset |
src/engine/BotBrain.ts |
Pure AI decision logic: personality-parameterized action/challenge/block choices with card counting, bluff persistence, and deck memory |
src/server/RoomManager.ts |
Room lifecycle: create, join, rejoin, leave, cleanup (24h TTL), chat storage, rematch reset, bot management |
src/server/SocketHandler.ts |
Socket.io event routing: validates context, delegates to engine |
src/server/StateSerializer.ts |
Per-player state filtering before sending to clients |
src/server/BotController.ts |
Bot timing/execution: schedules AI decisions with randomized delays, triggers bot emotes |
server.ts |
Entry point: wires Express + Socket.io + Next.js |
src/app/page.tsx |
Home screen UI (create/join room) |
src/app/hooks/useSocket.ts |
Socket.io client hook with reconnection and session storage |
src/app/stores/gameStore.ts |
Zustand store: connection, room, game, chat, sound, reactions, error |
src/app/stores/settingsStore.ts |
Zustand store: hapticEnabled, textSize (persisted to localStorage) |
src/app/utils/haptic.ts |
Haptic feedback: vibration API with iOS Safari checkbox-switch fallback |
src/app/audio/SoundEngine.ts |
Web Audio API synthesizer: 21+ sound types, mute toggle |
src/app/components/game/GameTable.tsx |
Main game layout component |
src/app/components/chat/ChatPanel.tsx |
Chat message list + text input |
src/app/components/game/GameCenterTabs.tsx |
Log/Chat tabbed container with unread indicator |
src/app/components/game/GameOverOverlay.tsx |
Game over screen with rematch flow |
src/app/components/game/ReactionPicker.tsx |
Emoji reaction selector (12 reactions) |
src/app/components/game/ReactionBubble.tsx |
Displays active reaction above a player seat |
src/app/components/settings/SettingsModal.tsx |
Settings: sound, haptic feedback, text size, bug report/feedback links |
src/app/components/lobby/AddBotModal.tsx |
Modal with name input + personality selector (7 personality buttons) for adding bots |
src/app/components/game/ExaminePrompt.tsx |
Inquisitor examine decision UI: shows revealed card, Return or Force Swap buttons |
src/app/components/icons/InquisitorIcon.tsx |
Inquisitor SVG icon (all-seeing eye, teal) |
docs/REFORMATION_PLAN.md |
Full implementation plan for the Reformation expansion |
- Add the action to
ActionTypeenum insrc/shared/types.ts - Add its definition to
ACTION_DEFINITIONSinsrc/shared/constants.ts - Handle it in
ActionResolver.declareAction()andActionResolver.resolveAction() - Add UI for it in
src/app/components/game/ActionBar.tsx
- Add the event signature to
ClientToServerEventsorServerToClientEventsinsrc/shared/protocol.ts - Add the handler in
src/server/SocketHandler.ts - Add the client-side emit/listener in
src/app/hooks/useSocket.ts
Room-scoped chat works in both lobby and in-game. Messages are stored server-side per room (up to CHAT_MAX_HISTORY), rate-limited to 1 per second per player, and sent to rejoining players via chat:history. In-game, the GameCenterTabs component provides Log and Chat tabs with an unread indicator.
The host can add 1–5 AI players from the lobby via bot:add. Each bot has a personality (BotPersonality = 'aggressive' | 'conservative' | 'vengeful' | 'deceptive' | 'analytical' | 'optimal' | 'random'):
- Aggressive — High bluff rates, offensive actions, always targets leader, aggressive challenges
- Conservative — Very low bluff rates, prefers safe actions (Income/Foreign Aid), rarely challenges
- Vengeful — Retaliates against recent attackers (revenge targeting scans last ~20 log entries), moderate bluff rates
- Deceptive — Highest bluff rates across all action types, avoids challenging (doesn't want others to challenge either), high bluff persistence
- Analytical — Low-moderate bluffs, high evidence-based challenge rates, strong leader targeting, steeper card value ranking
- Optimal — Strategic card counting, selective bluffing, bluff persistence, always targets highest-coin player, uses
dynamicCardValue()for context-aware card ranking, prefers Steal in 1v1, endgame tactics - Random — Picks one of the 6 concrete personalities at game start (hidden from player)
All bots use the same underlying architecture: card counting, bluff persistence, deck memory, and endgame tactics. The personality parameters (defined in BOT_PERSONALITIES in constants.ts, typed as PersonalityParams in types.ts) modulate behavior with ~18 behavioral parameters.
The default personality is 'random' (defined as DEFAULT_BOT_PERSONALITY in constants). The lobby UI presents 7 color-coded personality buttons: Random (purple), Aggressive (red), Conservative (green), Vengeful (orange), Deceptive (pink), Analytical (blue), Optimal (yellow).
Bots are server-side only — they use the same GameEngine methods as human players but decisions are made by BotBrain (pure logic, no I/O) and scheduled by BotController (timing layer with randomized delays: 1.5–3.5s for actions, 0.8–2s for reactions). Only one bot acts at a time; each action triggers a state change which cascades to the next bot.
Key behaviors:
- Bots never peek at opponents' hidden cards or the deck (they only use publicly revealed card information for card counting)
- When targeted by an action the bot can block with a card it holds (e.g., Contessa vs Assassination), it passes the challenge phase and blocks instead
- Bots survive rematch (
resetToLobbypreserves them with personality preserved), but a bot can never become host - State broadcasts skip bots (no socket to send to)
- Personality badges are shown next to the BOT badge in the lobby player list (color-coded per personality). Random bots show "RANDOM" in the lobby (the resolved personality is hidden)
- Bots fire emoji reactions via personality-driven emote system: each bot has
emotiveness(0–1) andmeanness(0–1) traits that determine reaction frequency and tone (nice vs mean reactions). Emotes are context-aware (triggered by game events like eliminations, challenges, blocks) and bluff-safe (~15% chance to skip reactions that could leak information about hidden cards)
After a game finishes, the host can click "Play Again" which triggers game:rematch → server calls resetToLobby() (destroys engine and BotController, clears game state, removes disconnected human players, preserves bots) → broadcasts game:rematch_to_lobby → all clients clear game state and redirect to the lobby. Chat history is preserved across rematches.
The Reformation expansion adds factions, new actions, and the Inquisitor character. It is toggled per-room via RoomSettings.gameMode (Classic/Reformation) and RoomSettings.useInquisitor.
Key concepts:
- Factions (
Faction.Loyalist/Faction.Reformist) — assigned alternating at game start. Cannot target same-faction with Coup/Assassinate/Steal/Examine. Restrictions lift when all alive players share a faction (Game.allSameFaction()) - Treasury Reserve (
Game.treasuryReserve) — separate coin pool. Filled by Convert payments, emptied by Embezzle - Convert — pay 1 coin (self) or 2 coins (other) to the reserve, flip faction. Not challengeable/blockable
- Embezzle — take all reserve coins. Uses inverse challenge: challenger wins if embezzler HAS Duke (they lied about not having it)
- Inquisitor — replaces Ambassador when
useInquisitoris true. Exchange draws 1 card (not 2). Examine action looks at opponent's card → force swap or return. Blocks Steal - Deck configuration —
Deck.setExcludedCharacters()swaps Ambassador/Inquisitor. Both exist inCharacterenum but only one is in the deck per game useInquisitorfield onGameState/ClientGameState— all client components use this to conditionally show Ambassador vs Inquisitor text, icons, and block options
Phase flow for Examine:
- Player declares Examine →
AwaitingActionChallenge - If unchallenged → server sets
examineStatewith revealed card AwaitingExamineDecision→ Inquisitor sees card, decides Return or Force Swap- →
ActionResolved
The resolver never mutates game state directly. Instead, it returns side effects like:
{ type: 'give_coins', playerId: '...', amount: 3 }
{ type: 'reveal_influence', playerId: '...', influenceIndex: 0 }
{ type: 'log', message: 'Alice collects Tax (+3 coins).' }
{ type: 'advance_turn' }
{ type: 'set_timer', durationMs: 15000 }
// Reformation expansion:
{ type: 'transfer_to_reserve', playerId: '...', amount: 2 }
{ type: 'take_from_reserve', playerId: '...' }
{ type: 'change_faction', playerId: '...', newFaction: Faction.Reformist }The GameEngine.applySideEffect() method interprets each effect and mutates the Game accordingly.
- Server is authoritative -- never add game logic to the client
- Types live in
src/shared/-- do not define game types in engine or server files - Engine has no I/O -- no
setTimeout, nosocket.emit, noconsole.loginActionResolver. Timers and logging are expressed as side effects - All game constants are in
src/shared/constants.ts-- do not hardcode magic numbers - Room codes are 6 characters using
ABCDEFGHJKLMNPQRSTUVWXYZ23456789(no ambiguous characters like O/0/I/1) - Player IDs are UUIDs generated server-side
- State broadcasts go to every connected human player in the room, each receiving their own filtered view (bots are skipped)
- Bots use the same engine API --
BotBrainis pure logic (no I/O),BotControllerhandles timing. Never add socket or timer logic toBotBrain
- Tests live in the top-level
tests/directory, mirroringsrc/structure:tests/engine/andtests/server/ - Test imports use the
@/path alias (e.g.,import { Game } from '@/engine/Game') - Engine tests should test the
ActionResolverandGameclasses directly, without sockets - Create players and a game programmatically, then call resolver methods and assert on the returned
ResolverResult - Use
vitest-- the config is invitest.config.ts - Test edge cases: steal from player with 1 coin, exchange with 1 influence, forced coup at 10 coins, challenge on a truthful claim vs. a bluff