Files
open-claw-team/.openclaw/workspace/docs/superpowers/plans/2026-03-31-lotto-game-full-stack.md
T

35 KiB
Raw Blame History

Production-Ready Lô Tô Game — Full Implementation Plan

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

Goal: Build a full-stack multiplayer Lô Tô game (Node.js backend + React frontend) that is production-ready, deployable to Zalo Mini Apps, with robust testing, authentication, real-time gameplay, and full economy/social features.

Architecture: Server-authoritative WebSocket game server (Socket.io) with React SPA frontend, using PostgreSQL for persistence and Redis for real-time state/rate limiting. All game logic validated server-side; client uses optimistic UI.

Tech Stack:

  • Backend: Node.js, Express, Socket.io, JSON Web Tokens (JWT), PostgreSQL (Supabase/Neon), Redis (Upstash)
  • Frontend: React 18, Vite, Socket.io-client, Framer Motion, Zustand (state), Tailwind CSS (styling)
  • Testing: Jest (unit), Cypress (E2E multi-player)
  • Deployment: Docker, Vercel (frontend), Railway/Render (backend), GitHub Actions (CI)
  • Auth: JWT, Zalo OAuth (Phase 2), anonymous temp users (Phase 1)

Pre-Implementation Setup

Before any tasks, ensure environment:

  • Create directory structure for frontend (client/) and backend (server/)
  • Initialize Git repository (if not already)
  • Create .gitignore (Node, React, VSCode,OS, env files)
  • Set up root docker-compose.yml for local DB+Redis
  • Create .env.example with all required env vars: DB URLs, JWT secret, Redis URL, etc.
  • Set up root package.json workspaces or separate package.json server/client
  • Configure ESLint + Prettier for consistency

Phase 1: Foundation (Backend Core) — 2 weeks

Task 1: Initialize Backend Project & Docker Compose

Files:

  • Create: server/package.json

  • Create: server/.env.example

  • Create: server/.eslintrc.js

  • Create: server/README.md

  • Create: docker-compose.yml at repo root

  • Create: .gitignore

  • Step 1: Write failing test — not applicable (setup task)

  • Step 2: Create server/package.json with dependencies: express, socket.io, pg, ioredis, jsonwebtoken, uuid, dotenv, winston, cors, helmet

  • Step 3: Create docker-compose.yml with services: postgres (image: postgres:15), redis (image: redis:7)

  • Step 4: Create .env.example listing all env vars (DATABASE_URL, REDIS_URL, JWT_SECRET, PORT=3001, NODE_ENV=development)

  • Step 5: Create server README with setup instructions

  • Step 6: Commit with message "feat: initialize backend project and docker compose"

Task 2: Database Schema Migration

Files:

  • Create: server/db/migrations/001_initial_schema.sql

  • Create: server/db/index.js ( connects to PostgreSQL using pg or supabase client)

  • Create: server/db/redisClient.js (connects to Redis)

  • Step 1: Write failing test — create server/db/schema.test.js that imports the schema file and checks that all tables exist using raw SQL introspection (skip in CI if DB not up)

  • Step 2: Write SQL migration with all tables and indexes exactly as spec (users, rooms, room_players, coin_transactions, game_history)

  • Step 3: Write db/index.js that exports pool with DATABASE_URL connection; include query helper that logs slow queries (>100ms)

  • Step 4: Write db/redisClient.js that creates Redis client from REDIS_URL, exports with get/set/sadd/zadd wrappers

  • Step 5: Run test — ensure migration SQL can be applied (docker compose up -d; psql -f migration)

  • Step 6: Commit "feat: database schema and clients"

Task 3: JWT Auth Service (Anonymous)

Files:

  • Create: server/services/auth.js

  • Create: server/middleware/auth.js

  • Create: server/routes/auth.js

  • Modify: server/index.js (or server/app.js) to register auth routes

  • Step 1: Write failing testserver/services/auth.test.js:

    • test createAnonymousUser() returns a JWT and creates user record with temp_id
    • test authenticateJWT() validates token and returns user payload
  • Step 2: Implement auth.service:

    • generateTempUserId() — UUID v4
    • createAnonymousUser() — INSERT into users (temp_id, coins=1000) RETURNING id, temp_id, coins; sign JWT with payload { userId, tempId, type: 'anonymous' }
    • verifyToken(token) — verify JWT secret, return payload
  • Step 3: Implement auth.middleware that extracts Bearer token, calls verifyToken, attaches req.user

  • Step 4: Implement auth.route:

    • POST /api/auth/anonymous → creates anonymous account, returns { token, user: { id, coins } }
    • GET /api/auth/me → returns user from token
  • Step 5: Wire up server to use routes and middleware

  • Step 6: Run tests — pass

  • Step 7: Commit "feat: anonymous JWT auth"

Task 4: Socket.io Server Setup

Files:

  • Create: server/socket/socketServer.js

  • Create: server/socket/handlers/roomHandlers.js

  • Create: server/socket/handlers/gameHandlers.js

  • Modify: server/index.js to initialize Socket.io and attach handlers

  • Step 1: Write failing testserver/socket/socketServer.test.js using socket.io-client to connect, emit create_room, expect room_created event (mock DB)

  • Step 2: Implement socketServer.js:

    • setupSocket(server) attaches Socket.io to HTTP server
    • uses socket.use(authMiddleware) to authenticate JWT
    • registers namespaces (none, default)
  • Step 3: Implement roomHandlers.js:

    • onCreateRoom(socket, data) validates betAmount, maxPlayers, highlightSeconds; generates room code; creates room record; joins socket to room; emits room_created
    • onJoinRoom(socket, {roomCode}) validates code; adds player to room_players; joins socket room; emits room_joined with state
    • onReady(socket) marks player ready; if all ready, transitions to playing after countdown
  • Step 4: Implement gameHandlers.js (stubs):

    • onPlaceGrain — validations (server-authoritative) will be added later
    • onCallKinh — stub: push to Redis claims set
    • onChat — broadcast to room
  • Step 5: Wire in socketServer to index.js

  • Step 6: Run test — pass

  • Step 7: Commit "feat: socket.io server and room/game handlers"

Task 5: Rate Limiting Middleware

Files:

  • Create: server/middleware/rateLimit.js

  • Step 1: Write failing testserver/middleware/rateLimit.test.js:

    • Simulate 10 requests from same user within second; first N allowed, rest blocked with 429
    • TTL expiration allows after 1s
  • Step 2: Implement rateLimit middleware:

    • Uses Redis key rate_limit:{userId}:{action} with INCR and EXPIRE 1s
    • Allows max 10 requests per second per action type (grain_placement, call_kinh, chat)
    • Returns 429 with JSON { error: 'rate_limited' } if exceeded
    • Attachable to socket.io via socket.use(...)
  • Step 3: Apply rate limiting to place_grain, call_kinh, chat events in socket handlers

  • Step 4: Run tests — pass

  • Step 5: Commit "feat: rate limiting per user action"

Task 6: Balance Reservation at Join

Files:

  • Modify: server/socket/handlers/roomHandlers.js (join_room)

  • Create: server/services/coinService.js

  • Create: server/services/coinService.test.js

  • Step 1: Write failing test — test reserveBet(userId, roomBet) deducts from users.coins and logs coin_transaction type=BET_PLACED with negative amount; test failure when insufficient coins

  • Step 2: Implement coinService:

    • reserveBet(userId, amount) — atomic: UPDATE users SET coins = coins - amount WHERE id=$1 AND coins >= amount; if row count 0 throw insufficient; then INSERT coin_transaction (type=BET_PLACED, amount=-amount, balance_after = (SELECT coins FROM users WHERE id=$1))
    • refundBet(userId, amount) — opposite (used on leave before start)
    • applyWin(userId, amount, roomId) — add coins, log KINH_WON
    • applyPenalty(userId, amount, roomId) — subtract coins, log KINH_PENALTY
  • Step 3: Wire join_room to call reserveBet when player joins; if fails, emit error and reject join

  • Step 4: Run tests — pass

  • Step 5: Commit "feat: coin reservation on room join"


Phase 2: Core Gameplay Engine — 2 weeks

Task 7: Number Draw Engine

Files:

  • Create: server/services/drawEngine.js

  • Create: server/services/drawEngine.test.js

  • Modify: server/socket/handlers/gameHandlers.js (add startGame)

  • Step 1: Write failing test — test shuffleNumbers() returns array of 90 unique numbers; test drawNext() returns number not previously drawn

  • Step 2: Implement drawEngine:

    • createDeck() returns shuffled numbers 1..90 using Fisher-Yates
    • DrawSession class: holds remaining deck, drawn array, current index. Method drawNext() returns next number, stores in drawn array with timestamp
  • Step 3: Implement startGame(roomId) handler:

    • Fetch room players; verify all ready or owner forced start
    • Create DrawSession for room (store in Redis: room:{roomId}:draw_state = JSON with deck)
    • Set room status = 'playing'; broadcast game_start with empty drawnNumbers
    • Begin async loop: for each number, setTimeout highlightSeconds*1000, then emit number_drawn and highlight_end
  • Step 4: Run tests — pass

  • Step 5: Commit "feat: number draw engine with broadcast"

Task 8: Rice Grain Placement Validation

Files:

  • Create: server/services/boardService.js

  • Create: server/services/boardService.test.js

  • Modify: server/socket/handlers/gameHandlers.js (onPlaceGrain)

  • Step 1: Write failing test — test canPlaceGrain(userId, row, col, drawnNumbers) returns false if column's number not in drawnNumbers; true if drawn; also check max 5 grains per row

  • Step 2: Implement boardService:

    • getNumberForCell(row, col) returns number (190) based on standard Lô Tô card layout (3 rows × 9 cols, 5 numbers per row distributed by decades)
    • isNumberDrawn(num, drawnNumbers) — includes in array check
    • canPlaceGrain(userId, row, col, drawnNumbers, roomPlayers) — ensures user has not exceeded 5 grains per row; cell not already occupied by that user; number drawn; bet reserved sufficient
    • placeGrain(userId, row, col) — records grain placement (in Redis set room:{roomId}:player:{userId}:grains serialized) and broadcast to room
  • Step 3: in onPlaceGrain, validate with boardService; if valid, update Redis and broadcast grain_placed event with position; if invalid, emit error

  • Step 4: Run tests — pass

  • Step 5: Commit "feat: rice grain placement validation"

Task 9: Concurrent Kinh Handling

Files:

  • Modify: server/socket/handlers/gameHandlers.js (onCallKinh)

  • Create: server/services/kinhService.js

  • Create: server/services/kinhService.test.js

  • Step 1: Write failing test — test validateKinh(userId, roomId) returns true if all rows that have grains fully match drawnNumbers; false otherwise

  • Step 2: Implement validateKinh:

    • Get user's grains from Redis; compute rows where they have exactly 5 grains; check every grain's number is in drawnNumbers
    • Returns object { valid: boolean, rowsCompleted: number[] }
  • Step 3: Implement onCallKinh:

    • When received, add to Redis sorted set room:{roomId}:kinh_claims with score=Date.now()
    • Broadcast kinh_claim to room so UI shows pending badge
    • Do not respond immediately; wait for freeze window end
  • Step 4: Modify draw engine:

    • After highlight_end emitted, set a short freeze timeout (500ms)
    • After freeze, collect all userIds from Redis sorted set (score within window); clear set; process all claims via processKinhClaims(roomId, claims)
  • Step 5: Implement processKinhClaims:

    • For each claimant, run validateKinh
    • Separate into validWinners and invalidClaimants
    • Compute totalPot = sum of all bets (roomBet * numPlayers)
    • Compute reward per valid = totalPot / validCount (integer math; any remainder left in pot? Could add to next round or house. We'll do integer division, remainder stays with house for simplicity)
    • For each invalid, penalty = betAmount to every other player in room (including other invalids). Implement loop: for each invalid p, for each other player q in room_players, transfer betAmount from p to q using coinService
    • For each valid, reward = their share (already computed) added to balance via coinService
    • Update room status to 'settling'; then 'settled' after DB updates
    • Broadcast game_settled with full results (including pre/post balances)
  • Step 6: Run tests — pass (use unit tests to validate math; integration will test end-to-end)

  • Step 7: Commit "feat: concurrent Kinh verification and settlement"


Phase 3: Frontend Core — 2 weeks

Task 10: Initialize React Frontend with Vite

Files:

  • Create: client/package.json

  • Create: client/vite.config.js

  • Create: client/index.html

  • Create: client/src/main.jsx

  • Create: client/src/App.jsx

  • Create: client/tailwind.config.js (or use plain CSS per retro theme)

  • Create: client/.env.example

  • Step 1: Create client/package.json with deps: react, react-dom, vite, socket.io-client, framer-motion, zustand, axios, tailwindcss

  • Step 2: Create vite.config.js for React, set base for Zalo Mini App later (base: './'), proxy API to backend for dev (server.proxy)

  • Step 3: Create index.html simple root div

  • Step 4: Create main.jsx ReactDOM.render into #root

  • Step 5: Create App.jsx with basic routing (Lobby vs Game) using React state for now

  • Step 6: Create .env.example with VITE_API_URL, VITE_WS_URL

  • Step 7: Commit "feat: initialize React frontend with Vite"

Task 11: Authentication Context & Login UI

Files:

  • Create: client/src/contexts/AuthContext.jsx

  • Create: client/src/hooks/useAuth.js

  • Create: client/src/components/Auth/AuthModal.jsx

  • Create: client/src/components/Auth/LoginButton.jsx

  • Modify: client/src/App.jsx to use AuthContext and show login UI

  • Step 1: Write failing testclient/src/contexts/AuthContext.test.jsx with Jest Testing Library: test that after login, context has token and user; test localStorage persistence

  • Step 2: Implement AuthContext:

    • State: user (null or {id, coins}), token (string), loading
    • Effect on mount: check localStorage for token; if present, call /api/auth/me to validate; else clear
    • loginAnonymous() calls POST /api/auth/anonymous, saves token+user to localStorage, updates state
    • logout() clears state and localStorage
  • Step 3: Implement LoginButton that triggers loginAnonymous; also placeholder Zalo button (disabled behind flag)

  • Step 4: Implement AuthModal that shows login options, closes after login

  • Step 5: Wire App.jsx: if not authenticated, show login modal; else show main app (lobby placeholder)

  • Step 6: Run tests — pass

  • Step 7: Commit "feat: anonymous auth context and UI"

Task 12: WebSocket Hook & Socket Context

Files:

  • Create: client/src/hooks/useWebSocket.js

  • Create: client/src/contexts/GameContext.jsx

  • Create: client/src/services/socketService.js

  • Step 1: Write failing testclient/src/hooks/useWebSocket.test.js: mock socket.io-client; test that on connection, state is connected; on event, handler called

  • Step 2: Implement socketService.js:

    • Singleton that creates socket instance from io() with auth token
    • Methods: on(event, handler), emit(event, payload), disconnect()
    • Auto-reconnect logic
  • Step 3: Implement useWebSocket hook that provides socket object via context

  • Step 4: Implement GameContext:

    • State: room, players, gameState ('lobby','playing','frozen','settled'), currentNumber, myGrains, etc.
    • Socket event listeners: room_joined, player_joined, number_drawn, highlight_end, kinh_claim, game_settled, balance_update, error
    • Methods: createRoom, joinRoom, ready, placeGrain, callKinh, sendChat, leave
  • Step 5: Run tests — pass

  • Step 6: Commit "feat: WebSocket hook and game context"

Task 13: Lobby & Room Management UI

Files:

  • Create: client/src/components/Lobby/LobbyList.jsx

  • Create: client/src/components/Lobby/RoomCard.jsx

  • Create: client/src/components/Lobby/CreateRoomModal.jsx

  • Modify: client/src/App.jsx to show lobby when authenticated

  • Step 1: Write failing testclient/src/components/Lobby/LobbyList.test.jsx: test that room cards display bet amount, player count; test join button calls joinRoom

  • Step 2: Implement CreateRoomModal with form fields: bet amount (input), max players (select 2-8), highlight seconds (select 1-5). Submits to createRoom from GameContext.

  • Step 3: Implement RoomCard to show room code, bet, players count, status; join button disabled if full or started

  • Step 4: Implement LobbyList that fetches public rooms from /api/rooms/public (backend stub needed later) or via socket event public_rooms (we'll add that to backend later). For now, mock data; later will wire to backend.

  • Step 5: Wire App.jsx to show LobbyList after login; show CreateRoomModal button

  • Step 6: Run tests — pass

  • Step 7: Commit "feat: lobby UI and room creation"


Phase 4: Core Gameplay Frontend — 2 weeks

Task 14: Lotto Card & Board

Files:

  • Create: client/src/components/Game/LottoCard.jsx

  • Create: client/src/utils/cardPositions.js

  • Modify: client/src/components/Game/GameBoard.jsx (to be created)

  • Step 1: Write failing testclient/src/components/Game/LottoCard.test.jsx: test that grid has 3 rows, 9 cols; test clicking cell triggers placeGrain; test grains display as placed

  • Step 2: Implement cardPositions.js with getNumberForCell(row, col) (0-indexed rows 0-2, cols 0-8). Rules: Column 0 = numbers 1-9; column 1 = 10-19; ... column 8 = 81-90? Actually Lô Tô: 9 columns represent tens: 1-9,10-19,...,80-90. Each column has 3 rows, but only 5 numbers per row across columns. Standard layout: Each row contains 5 numbers placed in specific column positions based on decade. We need precise mapping. Quick reference: traditional Lô Tô card:

    • Columns: 1 (1-9), 2 (10-19), 3 (20-29), 4 (30-39), 5 (40-49), 6 (50-59), 7 (60-69), 8 (70-79), 9 (80-90)
    • Rows: top, middle, bottom. Each row has 5 numbers; the distribution across columns follows rules: each row includes exactly 5 numbers from 5 different columns. There are standard patterns but we can simplify: randomly assign 5 numbers (from the column's range) to each row, ensuring each column used at least once across rows? For simplicity, we can predefine a fixed pattern for all cards: each card identical layout. Let's define a static mapping array CELL_NUMBERS[3][9] where each cell has a number (1-90) or null. Only 15 cells are numbers; others empty visual. We'll create a deterministic layout (same for all players) to avoid needing dynamic generation. We'll hardcode a valid Lô Tô card pattern: e.g., Row1: 1,11,21,31,41 at columns 1,2,3,4,5; others empty. Row2: 51,61,71,81,91? Wait 91 doesn't exist. Let's design properly: We need 5 numbers per row across 9 columns; each column appears at most once per row. Use this pattern (just an example): Row1: col0=1, col1=11, col2=25, col3=33, col4=44, others null Row2: col0=5, col1=17, col2=28, col4=49, col6=62 Row3: col1=20, col3=39, col5=58, col7=79, col8=90 We'll make a specific valid layout and hardcode it. It's not random; all players have same card pattern. That's fine for MVP. Implement getNumberForCell(row, col) returns that hardcoded number or null.
  • Step 3: Implement LottoCard component:

    • Renders a 3x9 grid using CSS grid.
    • For each cell: if number exists, clickable area; if null, empty placeholder with retro styling.
    • Shows rice grain overlay if cell is in grains set (from GameContext)
    • If cell's number is in drawnNumbers, highlight with CSS class
    • onClick calls placeGrain(row, col)
  • Step 4: Implement GameBoard:

    • Contains LottoCard, NumberBag animation, current number display, Kinh button, players panel
    • Uses Framer Motion for NumberBag (shake) when number being drawn
  • Step 5: Run tests — pass

  • Step 6: Commit "feat: Lotto card and game board UI"

Task 15: Framer Motion Animations

Files:

  • Modify: client/src/components/Game/NumberBag.jsx (new file)

  • Modify: client/src/components/Game/RiceGrain.jsx (new file)

  • Modify: client/src/styles/retroTheme.js

  • Step 1: Write failing test (visual, manual) — skip unit; will verify visually

  • Step 2: Create NumberBag component with animation using Framer Motion: while isShaking true, rotate and scale; while drawing emits number ball with motion

  • Step 3: Create RiceGrain component — when placed, animate drop (y: -100 to 0 with bounce) using motion.div

  • Step 4: Create retroTheme.js with color palette: nâu gỗ, vàng ố, đỏ đất, xanh lá cũ; fonts: handwriting retro; textures optional (use CSS patterns)

  • Step 5: Apply retro theme to components (buttons, cards)

  • Step 6: Manual QA: run app, verify animations smooth (≥30fps)

  • Step 7: Commit "feat: animations and retro styling"

Task 16: In-Game HUD & Controls

Files:

  • Create: client/src/components/Game/PlayersPanel.jsx

  • Create: client/src/components/Game/KinhButton.jsx

  • Create: client/src/components/Game/ChatPanel.jsx

  • Modify: client/src/components/Game/GameBoard.jsx to include these

  • Step 1: Write failing testclient/src/components/Game/KinhButton.test.jsx: test disabled when not playing; click calls callKinh

  • Step 2: Implement PlayersPanel:

    • Shows list: avatar, name, ready status, balance (pre-game), grains count
    • Updates via GameContext
  • Step 3: Implement KinhButton:

    • Visible only during playing and after user has completed at least one row (check row completeness from grains)
    • Clicking emits callKinh; button disabled after click to prevent spam
    • Shows countdown timer if freeze window active? Could show "Verifying..." message
  • Step 4: Implement ChatPanel:

    • Simple input + send button; messages list scrolling; emits chat event; receives chat_message
    • No persistence beyond current room
  • Step 5: Run tests — pass

  • Step 6: Commit "feat: in-game HUD, players panel, Kinh button, chat"


Phase 4 (cont'd): Economy, Social & Polish — 2 weeks

Task 17: Coin Economy & Daily Bonus

Files:

  • Create: server/routes/economy.js

  • Create: server/services/coinService.js (extend with dailyBonus)

  • Create: client/src/components/Profile/CoinWallet.jsx

  • Create: client/src/components/Profile/DailyBonusButton.jsx

  • Step 1: Write failing testserver/services/coinService.test.js: test claimDailyBonus(userId) gives coins only if last claim >24h ago; updates last_claimed_at in users (add column); test failure if already claimed

  • Step 2: Add DB column last_daily_bonus_at TIMESTAMP to users (migration 002_add_daily_bonus.sql)

  • Step 3: Implement claimDailyBonus in coinService with 24h check; inserts coin_transaction type=DAILY_BONUS

  • Step 4: Create route POST /api/economy/daily-bonus protected by auth; calls service; returns new balance

  • Step 5: Frontend: DailyBonusButton that calls endpoint, shows next available time; update AuthContext user balance after success

  • Step 6: Run tests — pass

  • Step 7: Commit "feat: daily coin bonus system"

Task 18: Achievements System

Files:

  • Create: server/routes/achievements.js

  • Create: server/services/achievementService.js

  • Create: server/db/migrations/003_achievements.sql (tables: achievements, user_achievements)

  • Create: client/src/components/Profile/AchievementsList.jsx

  • Step 1: Write failing test — define achievement logic: e.g., FIRST_WIN (first Kinh win), CONCURRENT_KINH_3 (call Kinh with >=2 others simultaneously). Test that when game_settled event emitted, service checks and grants achievements; test duplicate prevention

  • Step 2: Create DB tables:

    • achievements (id, code unique, name, description, coin_reward)
    • user_achievements (user_id, achievement_id, earned_at, UNIQUE(user_id,achievement_id))
  • Insert seed data for several achievements

  • Step 3: Implement achievementService:

    • checkAndGrantAchievements(userId, event, payload) — switch on event (e.g., 'game_settled'); query user stats; if not earned, insert into user_achievements and add coin_reward via coinService
    • getUserAchievements(userId) for UI
  • Step 4: Wire achievement check into processKinhClaims after settlement: emit event to achievementService for each winner (and possibly others)

  • Step 5: Route GET /api/achievements returns list of all achievements with earned flag

  • Step 6: Frontend: AchievementsList component displays badges, earned status, coin rewards

  • Step 7: Run tests — pass

  • Step 8: Commit "feat: achievements system with coin rewards"

Task 19: Spin Wheel Mini-Game

Files:

  • Create: server/routes/spin-wheel.js

  • Create: server/services/spinWheelService.js

  • Create: client/src/components/Profile/SpinWheel.jsx

  • Step 1: Write failing test — test spinWheel(userId) returns random prize segment; test cooldown (24h); test probability distribution

  • Step 2: Add DB columns to users: last_spun_at TIMESTAMP

  • Step 3: Define wheel segments array: [{label: '100 coins', reward: 100, weight: 50}, {label: '500 coins', reward: 500, weight: 10}, {label: 'Try again', reward: 0, weight: 40}] etc.

  • Step 4: Implement spinWheelService:

    • Weighted random selection based on segments
    • Check cooldown; if available, grant coins via coinService, set last_spun_at, return prize
  • Step 5: Route POST /api/spin returns prize result and new balance

  • Step 6: Frontend: SpinWheel component with animated wheel (Framer Motion spin animation), button to spin (disabled if on cooldown), display result

  • Step 7: Run tests — pass

  • Step 8: Commit "feat: daily spin wheel mini-game"

Task 20: Chat System

Files:

  • Modify backend: server/socket/handlers/gameHandlers.js to store chat in Redis list room:{roomId}:chat (max 100) and broadcast chat_message

  • Modify frontend: client/src/components/Game/ChatPanel.jsx already created; ensure displays messages with sender name and timestamp

  • Step 1: Write failing test — integration test: two sockets in same room; one sends chat; other receives event with correct sender and message

  • Step 2: Implement server chat handler:

    • onChat(socket, { message }): validate non-empty, length < 200; record in Redis list (LTRIM to keep last 100); broadcast chat_message with userId, name (from room_players), message, timestamp
  • Step 3: Implement client ChatPanel: maintain local messages array; on chat_message event, append; scroll to bottom

  • Step 4: (Optional) Profanity filter — simple blacklist array; filter message before broadcast; if blocked, emit error to sender only

  • Step 5: Run tests — pass

  • Step 6: Commit "feat: in-room chat"

Task 21: Profile & Stats

Files:

  • Create: server/routes/profile.js

  • Create: client/src/components/Profile/ProfilePage.jsx

  • Modify: client/src/App.jsx add route for profile

  • Step 1: Write failing testserver/routes/profile.test.js: test GET returns user stats: total_wins, total_losses, recent history (last 10 coin_transactions)

  • Step 2: Implement route:

    • GET /api/profile → selects from users, left join coin_transactions, returns summary and recent history
  • Step 3: Frontend ProfilePage displays: avatar (from user record if Zalo, else placeholder), name, coins, win/loss counts, recent transactions in table, achievements section (link to AchievementsList), daily bonus button, spin wheel section

  • Step 4: Navigation to profile from lobby

  • Step 5: Run tests — pass

  • Step 6: Commit "feat: user profile and stats page"


Phase 5: Testing, Docker & Deployment — 1 week

Task 22: Jest Unit Tests (Comprehensive)

Files:

  • Create: server/jest.config.js

  • Create: client/jest.config.js (if using Jest for React)

  • Fill: multiple unit tests for all services: auth, coinService, boardService, drawEngine, kinhService

  • Step 1: Write tests for every service function (see earlier tasks). Aim 80%+ coverage.

  • Step 2: Configure Jest for server (babel or ts if using TS; we're using plain JS so default)

  • Step 3: Add test script to server/package.json: "test": "jest"

  • Step 4: Run coverage and fix any gaps

  • Step 5: Commit "test: comprehensive unit test suite"

Task 23: Cypress E2E Multi-Player Tests

Files:

  • Create: client/cypress.config.js

  • Create: client/cypress/e2e/create-room.cy.js

  • Create: client/cypress/e2e/gameplay.cy.js

  • Create: client/cypress/e2e/concurrent-kinh.cy.js

  • Step 1: Write failing test (setup): test that Cypress can visit app and create a room

  • Step 2: Implement create-room.cy.js:

    • Visit app, login anonymous, create room, verify room created, join second browser instance (using cy.origin or two windows simulation)
  • Step 3: Implement gameplay.cy.js:

    • 2 players join room, ready, game starts; simulate placing grains; call Kinh; verify settlement and balances
  • Step 4: Implement concurrent-kinh.cy.js:

    • 4 players; simulate near-simultaneous Kinh calls (some valid, some invalid); verify final balances match expected penalty distribution
  • Step 5: Add Cypress to client package.json; script "cypress:open" and "cypress:run"

  • Step 6: Run E2E suite to ensure passes

  • Step 7: Commit "test: Cypress E2E multi-player scenarios"

Task 24: Docker & Docker Compose for Production-Like Local

Files:

  • Create: Dockerfile (for backend)

  • Create: client/Dockerfile (for frontend static build)

  • Modify: docker-compose.yml to include services: frontend, backend, postgres, redis

  • Create: .dockerignore

  • Step 1: Write failing test — none

  • Step 2: Write backend Dockerfile:

    • Node 20 alpine
    • Copy package.json, install, copy server/, expose 3001, CMD node server/index.js
  • Step 3: Write frontend Dockerfile:

    • Node 20 alpine; copy client package.json; install; copy client/; npm run build; output static files; serve with nginx: nginx:alpine and copy build to /usr/share/nginx/html
  • Step 4: Update docker-compose to build and link services; set env vars; frontend depends on backend; network them

  • Step 5: Test local docker compose up — app accessible at http://localhost (frontend) and backend ws at ws://localhost:3001

  • Step 6: Commit "feat: Docker and docker-compose for full stack"

Task 25: GitHub Actions CI

Files:

  • Create: .github/workflows/ci.yml

  • Step 1: Write failing test — none

  • Step 2: Create CI workflow:

    • Triggers on push to main, PRs
    • Jobs:
      • backend-tests: setup Node, run npm ci, npm test in server/, also lint
      • frontend-tests: setup Node, run npm ci, npm test in client/ (if unit tests exist), also npm run build to ensure build succeeds
      • e2e-tests: uses Cypress Docker image; run npm run cy:run in client/; needs backend service running (docker compose up -d inside workflow? Use Cypress included services? Simpler: spin up backend via docker compose in before_script)
    • Upload artifacts (screenshots, videos) on failure
  • Step 3: Commit "ci: GitHub Actions pipeline"

Task 26: Free Tier Deployment Configuration

Files:

  • Create: client/vercel.json (or rely on defaults)

  • Create: railway.json for backend (or use Dockerfile)

  • Update: client/.env.production with VITE_API_URL and VITE_WS_URL pointing to Railway backend URL (which will be set as env at deploy time)

  • Create: server/.env.production.example with required vars (Railway provides DATABASE_URL etc.)

  • Step 1: Write failing test — none

  • Step 2: Create Vercel config (optional) for SPA routing redirects

  • Step 3: Prepare Railway deployment: ensure Dockerfile present; add railway.json if needed (just specify Docker build)

  • Step 4: Document environment variables required for production in README

  • Step 5: Commit "deploy: free tier configs for Vercel + Railway"

Task 27: Zalo Mini App Build Script

Files:

  • Create: client/package.json scripts: "build:zalo": "vite build --config vite.zalo.config.js"

  • Create: client/vite.zalo.config.js with appropriate base and output dir dist/zalo

  • Create: client/zalo.config.json (Zalo Mini App manifest) (placeholder)

  • Create: client/src/platforms/zalo/adapter.js to wrap Zalo SDK calls

  • Step 1: Write failing test — none

  • Step 2: Create Zalo-specific Vite config that sets base to ./, outputs to dist/zalo, builds as library? (Zalo Mini App expects a single JS file and assets). We'll produce static assets.

  • Step 3: Create zalo.config.json with appID, name, version, orientation, etc. (placeholders)

  • Step 4: Create Zalo adapter that provides methods: login(), share(), getUserInfo(). Use real SDK when in Zalo environment; fallback to mock in web.

  • Step 5: Update Auth flow to use ZaloLoginButton that calls adapter.login; on success, hit backend Zalo OAuth flow

  • Step 6: Build test: run npm run build:zalo and verify output in dist/zalo/

  • Step 7: Commit "feat: Zalo Mini App build configuration"


Post-Implementation: Local Development & QA

  • Ensure docker-compose up brings up everything
  • Backend runs on :3001, frontend on :5173 (Vite default)
  • Provide instructions in README for running locally and for deployment

Notes for Subagent Workers

  • TDD: ALWAYS write test FIRST, then code to make it pass. No exceptions.
  • Commits: Small, logical commits after each step with clear messages.
  • Self-review: After completing your assigned tasks, run the tests, lint, and check that your code follows the architecture in the spec.
  • Cross-review: After your task is complete and self-reviewed, you will be asked to review another worker's code. Use the 3-tier review criteria: Plan Alignment, Code Quality, Architecture, Documentation, Issues.
  • Blockers: If you encounter a dependency on another task that is not yet done, ask the orchestrator to reorder tasks or create a minimal stub.

Plan complete and saved to docs/superpowers/plans/2026-03-31-lotto-game-full-stack.md.

Execution approach: Subagent-Driven (you will dispatch fresh subagents per task or small task group). I will now begin spawning subagents for Phase 1 tasks sequentially, ensuring each task is fully implemented, reviewed, and committed before moving to the next.

Let's start with Task 1: Initialize Backend Project & Docker Compose.