Build a production-style fullstack monorepo for the card game War with:
- Real-time multiplayer via WebSockets
- Single-player vs AI
- Step-by-step war resolution
- Battle log persistence in Redis
- Opponent deck size always visible
- Automatic forfeit on disconnect/leave
- Old games expire after inactivity
- Docker & Docker Compose orchestration
- Redis for game state, logs, and pub/sub
- Comprehensive documentation
| Layer | Package | Version | Note |
|---|---|---|---|
| Root Monorepo | npm workspaces | built-in | |
| Containerization | docker / docker-compose | 29.x / 2.40.x | Desktop available |
| Data Store | redis | 7.x | via Docker Compose |
| Redis Client | ioredis | 5.x | |
| Backend Framework | fastify | 5.8.5 | |
| GraphQL | mercurius | 16.9.0 | Fastify-native GraphQL |
| GraphQL Core | graphql | 16.13.2 | peer dep for Mercurius |
| WebSocket Subscriptions | graphql-ws | 6.0.8 | Mercurius subscription driver |
| Backend Language | typescript | 5.8.x | via tsx for dev |
| Frontend Framework | nuxt | 3.21.2 | latest stable Nuxt 3 (SSR) |
| Frontend UI | vue | 3.5.33 | Composition API |
| State Management | pinia | 3.0.4 | |
| Pinia Nuxt Module | @pinia/nuxt | 0.11.3 | |
| Styling | tailwindcss | 4.2.4 | |
| Tailwind Nuxt Module | @nuxtjs/tailwindcss | 6.14.0 | |
| Testing | vitest | 4.1.5 | both frontend & backend |
| HTTP Client | $fetch (ofetch) | built-in | Nuxt universal fetch |
Decision: Using Nuxt 3.21.2 (latest stable 3.x) rather than Nuxt 4.x to honor the original
Nuxt 3spec while remaining current. Nuxt 4.x is markedlateston npm but the3xtag confirms 3.21.2 is the maintained LTS line.
war/
├── package.json # workspace root
├── docker-compose.yml # Redis + backend services
├── PLAN.md # this file
├── AGENTS.md # agent quick-start guide
├── README.md # user-facing setup & run guide
├── apps/
│ ├── backend/
│ │ ├── Dockerfile
│ │ ├── .dockerignore
│ │ ├── src/
│ │ │ ├── index.ts # entrypoint
│ │ │ ├── server.ts # Fastify factory
│ │ │ ├── graphql/
│ │ │ │ ├── schema.ts # SDL
│ │ │ │ ├── resolvers.ts # root resolvers
│ │ │ │ └── context.ts # context builder
│ │ │ ├── services/
│ │ │ │ ├── GameService.ts
│ │ │ │ ├── AIService.ts
│ │ │ │ └── DeckService.ts
│ │ │ ├── store/
│ │ │ │ ├── RedisStore.ts # ioredis wrapper
│ │ │ │ ├── GameRepository.ts # Redis game CRUD
│ │ │ │ ├── UserRepository.ts # Redis user CRUD
│ │ │ │ └── BattleLogRepository.ts # Redis list logs
│ │ │ ├── websocket/
│ │ │ │ └── RedisPubSub.ts # Redis pub/sub for subscriptions
│ │ │ └── types/
│ │ │ └── game.ts
│ │ ├── tests/
│ │ │ ├── GameService.test.ts
│ │ │ └── DeckService.test.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── frontend/
│ ├── Dockerfile
│ ├── .dockerignore
│ ├── pages/
│ │ ├── lobby.vue
│ │ └── game/
│ │ └── [id].vue
│ ├── components/
│ │ ├── GameBoard.vue
│ │ ├── BattleArena.vue
│ │ ├── Card.vue
│ │ ├── DeckPile.vue
│ │ ├── WarIndicator.vue
│ │ ├── GameLog.vue
│ │ ├── PlayTurnButton.vue
│ │ └── SpeedControl.vue
│ ├── stores/
│ │ ├── user.ts
│ │ ├── game.ts
│ │ └── socket.ts
│ ├── composables/
│ │ ├── useGraphQL.ts
│ │ └── useGameSocket.ts
│ ├── graphql/
│ │ ├── queries.ts
│ │ ├── mutations.ts
│ │ └── subscriptions.ts
│ ├── tests/
│ │ └── gameStore.test.ts
│ ├── nuxt.config.ts
│ ├── app.vue
│ ├── tailwind.config.ts
│ ├── package.json
│ └── tsconfig.json
└── packages/
├── types/
│ ├── src/
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
└── utils/
├── src/
│ └── index.ts
├── tests/
│ └── utils.test.ts
├── package.json
└── tsconfig.json
enum GameStatus {
WAITING
PLAYING
ENDED
FORFEITED
}
enum BattlePhase {
DRAW
WAR
RESOLVED
}
type User {
id: ID!
name: String!
isAI: Boolean!
}
type Card {
value: Int!
suit: String!
}
type BattleCard {
playerId: ID!
card: Card!
faceDown: Boolean!
}
type Player {
id: ID!
name: String!
deckSize: Int!
pileCount: Int!
isConnected: Boolean!
}
type CurrentBattle {
phase: BattlePhase!
cards: [BattleCard!]!
winnerId: ID
isWar: Boolean!
}
type GameLogEntry {
id: ID!
type: String!
message: String!
timestamp: String!
}
type Game {
id: ID!
status: GameStatus!
mode: String!
players: [Player!]!
currentBattle: CurrentBattle
winnerId: ID
logs: [GameLogEntry!]!
activePlayerId: ID
createdAt: String!
updatedAt: String!
}
type Query {
getGames: [Game!]!
getGame(gameId: ID!): Game
}
type Mutation {
createUser(name: String!): User!
createGame(mode: String!): Game!
joinGame(gameId: ID!, userId: ID!): Game!
startGame(gameId: ID!): Game!
playTurn(gameId: ID!, userId: ID!): Game!
leaveGame(gameId: ID!, userId: ID!): Game!
}
type Subscription {
gameUpdated(gameId: ID!): Game!
}Game State:
- Key:
game:{gameId} - Type: Hash
- Fields:
status,mode,players(JSON),currentBattle(JSON),winnerId,activePlayerId,createdAt,updatedAt - TTL: 1800 seconds (30 minutes) — refreshed on every update
Battle Logs:
- Key:
game:{gameId}:logs - Type: List
- Max length: 100 entries (LTRIM after LPUSH)
Users:
- Key:
user:{userId} - Type: Hash
- Fields:
id,name,isAI
Pub/Sub Channel:
- Channel:
game:{gameId}:updates - Payload: full Game JSON
States stored per Game (in Redis hash):
players[0..1].deck: Card[]— serialized as JSON string in hash fieldplayers[0..1].isConnected: booleancurrentBattle: { phase, cards[], winnerId, isWar }activePlayerId— whose turn it is to click Play TurnwinnerId— set when game endslogs[]— stored in separate Redis list
playTurn(gameId, userId) logic:
- Validation: game exists, started, not ended, is user's turn
- If
currentBattleis null orRESOLVED→ Start new battle (DRAW)- Each player draws top card →
currentBattle.cards phase = DRAW- Compare drawn cards
- If not equal:
winnerId = higher player,phase = RESOLVED, give pile to winner, switchactivePlayerId - If equal:
isWar = true,phase = WAR, do NOT switch active player
- Each player draws top card →
- If
currentBattle.phase === WAR→ Resolve war step- Check both players have ≥ 2 cards. If not, player with insufficient cards loses immediately.
- Each player draws 1 card (
faceDown: true) → pile - Each player draws 1 card (
faceDown: false) → pile - Compare the last drawn (face-up) cards
- If not equal:
winnerId = higher player,phase = RESOLVED, give entire pile to winner, switchactivePlayerId - If equal:
phase = WAR(stay in war, same player clicks again)
- After every mutation → publish to Redis pub/sub + append to battle logs + refresh TTL
AIServicewatches Redis keyspace notifications or polls game state changes- When
game.mode === 'ai'andactivePlayerId === aiPlayerId:setTimeout(500ms)→ callGameService.playTurn(gameId, aiUserId)
- AI user created automatically when AI game is created
BattleLogRepositoryuses Redis lists (LPUSH+LTRIM)- Every turn appends:
{ type: 'DRAW'|'WAR'|'RESOLVED'|'FORFEIT'|'EXPIRED', message, timestamp } - Logs exposed via
Game.logsGraphQL field (read viaLRANGE) - Max 100 entries per game (LTRIM to 99 after LPUSH)
- Every game hash key is set with
EXPIRE 1800(30 minutes) - On every game update,
EXPIREis refreshed - If a game expires in Redis:
- If status was
PLAYING, the game is considered expired; neither player wins - The expiry event can be caught via Redis keyspace notifications (optional)
- If status was
- Frontend can also poll
getGameand handlenullas "game expired"
leaveGame(gameId, userId)mutation sets playerisConnected = false- If game status is
PLAYING, the leaving player immediately forfeits:status = FORFEITEDwinnerId = opponentId- Log entry:
type: 'FORFEIT' - Publish final update
- If game status is
WAITING, the leaving player is simply removed; game returns toWAITINGfor new join - Frontend calls
leaveGameonbeforeunload/ page close for graceful exit - WebSocket disconnect also triggers
leaveGamevia server-side connection close handler
GameErrorextends Error with codes:GAME_NOT_FOUNDINVALID_ACTIONNOT_YOUR_TURNGAME_NOT_STARTEDINSUFFICIENT_CARDS
- Returned as structured GraphQL errors with
extensions.code
Inline // SCALE: comments mark:
RedisStore→ Redis Cluster or AWS ElastiCache for horizontal scalingBattleLogRepository→ append-only event store (Kafka/EventStoreDB) for audit trailsgraphql-ws→ sticky sessions or shared subscription state via RedisGameService→ snapshot game state to PostgreSQL every N turns for recovery
| Route | Purpose |
|---|---|
/lobby |
Name entry, create/join games, list open games |
/game/[id] |
Main game board |
┌─────────────────────────────────────┐
│ Opponent Deck (26) [connected] │ ← DeckPile, count always visible
│ │
│ ┌─────────┐ │
│ │ Battle │ │ ← BattleArena: face-up cards
│ │ Arena │ │ + face-down stacks in war
│ └─────────┘ │
│ WAR! │ ← WarIndicator (animated)
│ │
│ Your Deck (26) │ ← DeckPile
│ │
│ [ Play Turn ] │ ← PlayTurnButton
│ Speed: [1x ▼] [Auto-play] │ ← SpeedControl
│ ┌─────────────────────────────┐ │
│ │ Battle Log │ │ ← GameLog
│ │ • 10:05 — You drew 10♠ │ │
│ │ • 10:05 — AI drew 10♥ │ │
│ │ • 10:05 — WAR triggered! │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
userStore—id,name,isAI. Persisted tolocalStoragegameStore—game,loading,error. Computed:isMyTurn,myPlayer,opponentPlayer,warActive,battleResolvedsocketStore—graphql-wsclient, connection status (CONNECTED|DISCONNECTED|RECONNECTING), exponential backoff reconnection
- Page mount →
socketStore.connect(gameId)→ subscribe togameUpdated - User clicks Play Turn →
useGraphQLmutation via$fetch - Backend processes → publishes via Redis pub/sub
- Frontend subscription receives
gameUpdated→gameStore.game = payload - UI reacts via computed properties
gameStorepatches only top-levelgameobject reference- All derived UI state uses
computed BattleArenauses:key="battleCard.playerId + index"for Vue transitions- Battle log is append-only (Vue handles list diff efficiently)
- Network errors: toast notification + retry button
- GraphQL errors: parsed by
useGraphQL, shown inline - Disconnect:
socketStoreattempts exponential backoff reconnect - Desync: on reconnect, refetch
getGameto restore full state
- Standard 52-card deck, values 2–14 (Ace = 14), 4 suits
- Shuffled and split 26/26
- Each Play Turn advances one step:
- Normal: both reveal top card → compare → higher wins pile
- War: tie → each places 1 face-down + 1 face-up → compare again → repeat if tied
- If player lacks 2 cards during war → immediate loss
- Winner of battle collects all pile cards to bottom of deck
- Game ends when opponent has 0 cards or forfeits
All events flow through the single GraphQL subscription gameUpdated(gameId), which pushes the full Game object. The frontend derives specific UI states from Game fields.
Implicit events (derived from Game state):
TURN_PLAYED—currentBattle.phase === DRAWWAR_TRIGGERED—currentBattle.isWar === trueBATTLE_RESOLVED—currentBattle.phase === RESOLVEDGAME_UPDATED— any state changeGAME_ENDED—status === ENDED || status === FORFEITED
export type Suit = "HEARTS" | "DIAMONDS" | "CLUBS" | "SPADES";
export interface Card {
value: number; // 2-14
suit: Suit;
}
export enum GameStatus {
WAITING = "WAITING",
PLAYING = "PLAYING",
ENDED = "ENDED",
FORFEITED = "FORFEITED",
}
export enum BattlePhase {
DRAW = "DRAW",
WAR = "WAR",
RESOLVED = "RESOLVED",
}export interface GameEntity {
id: string;
status: GameStatus;
mode: "multiplayer" | "ai";
players: PlayerEntity[];
currentBattle: CurrentBattle | null;
winnerId: string | null;
logs: BattleLogEntry[];
activePlayerId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface PlayerEntity {
id: string;
name: string;
deck: Card[];
pileCount: number; // alias for deck.length
isConnected: boolean;
isAI: boolean;
}
export interface CurrentBattle {
phase: BattlePhase;
cards: BattleCard[];
winnerId: string | null;
isWar: boolean;
}
export interface BattleCard {
playerId: string;
card: Card;
faceDown: boolean;
}
export interface BattleLogEntry {
id: string;
type: "DRAW" | "WAR" | "RESOLVED" | "FORFEIT" | "EXPIRED";
message: string;
timestamp: Date;
}- Root
package.jsonwith npm workspaces -
docker-compose.ymlwith Redis service - Shared packages
typesandutilswith TypeScript config -
apps/backendpackage scaffold + Dockerfile -
apps/frontendNuxt 3 scaffold + Dockerfile - Install all dependencies
- Verify
npm installsucceeds at root - Verify
docker-compose up redisstarts Redis successfully
-
packages/types— enums and interfaces -
packages/utils—createDeck,shuffle,compareCards - Unit tests for utils
- All tests pass
- Fastify + Mercurius server boot
- GraphQL schema + resolvers (Queries, Mutations)
-
RedisStore— ioredis wrapper with connection management -
GameRepository— Redis game CRUD (hashes + TTL) -
UserRepository— Redis user CRUD -
BattleLogRepository— Redis list logs with LTRIM -
GameService—createGame,joinGame,startGame,playTurn,leaveGame -
DeckService— shuffle, split -
AIService— auto-play with delay -
RedisPubSub— Redis pub/sub for subscriptions - Battle log persistence on every turn
- Game TTL refresh on every update
- Forfeit on
leaveGame - Unit tests for GameService (normal, war, recursive war, insufficient cards)
-
graphql-wsintegration with Mercurius -
gameUpdatedsubscription backed by Redis pub/sub - WebSocket broadcasts on all state changes
- Test subscription via GraphQL playground
- Verify Redis pub/sub messages are received
- Nuxt 3 pages:
/lobby,/game/[id] - Tailwind CSS configured
- Pinia stores:
user,game,socket -
useGraphQLcomposable (mutation + query via$fetch) -
useGameSocketcomposable (subscription viagraphql-ws)
-
GameBoard.vue— layout shell -
DeckPile.vue— deck with count badge (opponent always visible) -
BattleArena.vue— card reveal area -
Card.vue— card visual -
WarIndicator.vue— animated war banner -
GameLog.vue— scrollable battle history -
PlayTurnButton.vue— disabled state, loading -
SpeedControl.vue— speed selector + auto-play toggle
- Lobby: create/join/list games
- Game page: load game, subscribe to updates
- Play Turn mutation + optimistic UI
- Handle
GAME_ENDED→ show winner modal - Reconnection logic with exponential backoff
- Leave game on page close / navigate away
-
docker-compose upstarts Redis + backend together - Backend Docker image builds and runs
- AI mode: create AI game → play full match → verify win/loss
- Multiplayer: two browser tabs → create + join → play full match
- War scenario: force tie → verify step-by-step war
- Forfeit: tab close → opponent wins
- Expiry: verify Redis TTL cleans up old games
-
README.md— setup, run, test, architecture overview -
AGENTS.md— agent quick-start - Inline code comments for scalability notes
- Verify all markdown files are complete
| Test File | Coverage |
|---|---|
packages/utils/tests/utils.test.ts |
createDeck returns 52 cards; shuffle randomizes; compareCards returns winner |
apps/backend/tests/GameService.test.ts |
Normal round; war; recursive war; insufficient cards = loss; AI auto-play |
apps/backend/tests/DeckService.test.ts |
Shuffle distribution; split equality |
apps/frontend/tests/gameStore.test.ts |
Store init; setGame updates; isMyTurn computed; warActive computed |
Run commands:
npm run test --workspace=packages/utils
npm run test --workspace=apps/backend
npm run test --workspace=apps/frontendversion: "3.8"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
backend:
build: ./apps/backend
ports:
- "3001:3001"
environment:
- REDIS_URL=redis://redis:6379
- PORT=3001
- NODE_ENV=development
depends_on:
- redis
volumes:
- ./apps/backend:/app
- /app/node_modules
frontend:
build: ./apps/frontend
ports:
- "3000:3000"
environment:
- NUXT_PUBLIC_API_URL=http://localhost:3001
- NUXT_PUBLIC_WS_URL=ws://localhost:3001/graphql
depends_on:
- backend
volumes:
- ./apps/frontend:/app
- /app/node_modules
volumes:
redis-data:FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3001
CMD ["npm", "run", "dev"]FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]Redis is used for state, logs, and pub/sub. This is production-grade for moderate scale but still single-node Redis.
- Redis Store: Upgrade to Redis Cluster or AWS ElastiCache for high availability.
- Pub/Sub: Redis Pub/Sub already supports multi-instance backends; just point all instances to the same Redis.
- WebSocket Connections: Use a load balancer with sticky sessions or migrate to a managed WebSocket gateway.
- Battle Logs: Move from Redis lists to an append-only event store (PostgreSQL with TimescaleDB, or Kafka) for long-term audit trails.
- Game Snapshots: Snapshot game state to PostgreSQL every N turns for crash recovery. Replay events from snapshot on restart.
- AI Service: Move AI decisions to a separate worker queue (BullMQ + Redis) to decouple from HTTP request cycle.
# Start Redis manually (requires local Redis installed)
redis-server
# Install dependencies
npm install
# Terminal 1 — Backend
npm run dev --workspace=apps/backend
# → http://localhost:3001
# → GraphQL Playground at /graphql
# Terminal 2 — Frontend
npm run dev --workspace=apps/frontend
# → http://localhost:3000# Start everything
docker-compose up --build
# Start only Redis
docker-compose up redis
# Start backend + Redis
docker-compose up backend redis
# Start all services
docker-compose upnpm run test --workspace=packages/utils
npm run test --workspace=apps/backend
npm run test --workspace=apps/frontend| Decision | Rationale |
| --------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------- |
| npm workspaces | Built-in, no extra tooling, matches user's npm environment |
| Mercurius over Apollo Server | Native Fastify integration, lower overhead, graphql-ws built-in support |
| Single subscription gameUpdated | Simpler frontend state management; derive all UI events from full Game object |
| Redis for state + logs + pub/sub | Production-grade in-memory store with TTL, persistence options, and built-in pub/sub |
| Step-by-step war | Dramatic UX; each click reveals one phase of war |
| Nuxt 3.21.2 | Latest stable 3.x line; Nuxt 4.x is latest on npm but may introduce breaking changes |
| tsx for backend dev | Fast, zero-config TypeScript execution for Node.js | eaks from abandoned games; adjustable via env var |
| Max 100 log entries | Prevents unbounded memory growth in long games |
| Docker Compose | One-command startup for full stack including Redis |