35 KiB
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.ymlfor local DB+Redis - Create
.env.examplewith all required env vars: DB URLs, JWT secret, Redis URL, etc. - Set up root
package.jsonworkspaces 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.ymlat 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 usingpgorsupabaseclient) -
Create:
server/db/redisClient.js(connects to Redis) -
Step 1: Write failing test — create
server/db/schema.test.jsthat 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_URLconnection; includequeryhelper that logs slow queries (>100ms) -
Step 4: Write db/redisClient.js that creates Redis client from
REDIS_URL, exports withget/set/sadd/zaddwrappers -
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(orserver/app.js) to register auth routes -
Step 1: Write failing test —
server/services/auth.test.js:- test
createAnonymousUser()returns a JWT and creates user record with temp_id - test
authenticateJWT()validates token and returns user payload
- test
-
Step 2: Implement auth.service:
generateTempUserId()— UUID v4createAnonymousUser()— 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
- POST
-
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.jsto initialize Socket.io and attach handlers -
Step 1: Write failing test —
server/socket/socketServer.test.jsusingsocket.io-clientto connect, emitcreate_room, expectroom_createdevent (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; emitsroom_createdonJoinRoom(socket, {roomCode})validates code; adds player to room_players; joins socket room; emitsroom_joinedwith stateonReady(socket)marks player ready; if all ready, transitions toplayingafter countdown
-
Step 4: Implement gameHandlers.js (stubs):
onPlaceGrain— validations (server-authoritative) will be added lateronCallKinh— stub: push to Redis claims setonChat— 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 test —
server/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(...)
- Uses Redis key
-
Step 3: Apply rate limiting to
place_grain,call_kinh,chatevents 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_WONapplyPenalty(userId, amount, roomId)— subtract coins, log KINH_PENALTY
-
Step 3: Wire join_room to call
reserveBetwhen 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(addstartGame) -
Step 1: Write failing test — test
shuffleNumbers()returns array of 90 unique numbers; testdrawNext()returns number not previously drawn -
Step 2: Implement drawEngine:
createDeck()returns shuffled numbers 1..90 using Fisher-YatesDrawSessionclass: holds remaining deck, drawn array, current index. MethoddrawNext()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_startwith empty drawnNumbers - Begin async loop: for each number,
setTimeouthighlightSeconds*1000, then emitnumber_drawnandhighlight_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 (1–90) based on standard Lô Tô card layout (3 rows × 9 cols, 5 numbers per row distributed by decades)isNumberDrawn(num, drawnNumbers)— includes in array checkcanPlaceGrain(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 sufficientplaceGrain(userId, row, col)— records grain placement (in Redis setroom:{roomId}:player:{userId}:grainsserialized) and broadcast to room
-
Step 3: in onPlaceGrain, validate with boardService; if valid, update Redis and broadcast
grain_placedevent 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_claimswith score=Date.now() - Broadcast
kinh_claimto room so UI shows pending badge - Do not respond immediately; wait for freeze window end
- When received, add to Redis sorted set
-
Step 4: Modify draw engine:
- After
highlight_endemitted, 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)
- After
-
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 playerqin room_players, transferbetAmountfrom 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_settledwith full results (including pre/post balances)
- For each claimant, run
-
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.jsxto use AuthContext and show login UI -
Step 1: Write failing test —
client/src/contexts/AuthContext.test.jsxwith 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/meto validate; else clear loginAnonymous()calls POST/api/auth/anonymous, saves token+user to localStorage, updates statelogout()clears state and localStorage
- State:
-
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 test —
client/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
socketinstance fromio()with auth token - Methods:
on(event, handler),emit(event, payload),disconnect() - Auto-reconnect logic
- Singleton that creates
-
Step 3: Implement useWebSocket hook that provides
socketobject 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
- State:
-
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.jsxto show lobby when authenticated -
Step 1: Write failing test —
client/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
createRoomfrom 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 eventpublic_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 test —
client/src/components/Game/LottoCard.test.jsx: test that grid has 3 rows, 9 cols; test clicking cell triggersplaceGrain; 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. ImplementgetNumberForCell(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
grainsset (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
isShakingtrue, rotate and scale; whiledrawingemits 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.jsxto include these -
Step 1: Write failing test —
client/src/components/Game/KinhButton.test.jsx: test disabled when not playing; click callscallKinh -
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
chatevent; receiveschat_message - No persistence beyond current room
- Simple input + send button; messages list scrolling; emits
-
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 test —
server/services/coinService.test.js: testclaimDailyBonus(userId)gives coins only if last claim >24h ago; updateslast_claimed_atin users (add column); test failure if already claimed -
Step 2: Add DB column
last_daily_bonus_at TIMESTAMPto users (migration002_add_daily_bonus.sql) -
Step 3: Implement
claimDailyBonusin coinService with 24h check; inserts coin_transaction type=DAILY_BONUS -
Step 4: Create route
POST /api/economy/daily-bonusprotected 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 coinServicegetUserAchievements(userId)for UI
-
Step 4: Wire achievement check into
processKinhClaimsafter settlement: emit event to achievementService for each winner (and possibly others) -
Step 5: Route
GET /api/achievementsreturns 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/spinreturns 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.jsto store chat in Redis listroom:{roomId}:chat(max 100) and broadcastchat_message -
Modify frontend:
client/src/components/Game/ChatPanel.jsxalready 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); broadcastchat_messagewith userId, name (from room_players), message, timestamp
-
Step 3: Implement client ChatPanel: maintain local messages array; on
chat_messageevent, 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.jsxadd route for profile -
Step 1: Write failing test —
server/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.originor two windows simulation)
- Visit app, login anonymous, create room, verify room created, join second browser instance (using
-
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.ymlto 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:alpineand copy build to/usr/share/nginx/html
- Node 20 alpine; copy client package.json; install; copy client/;
-
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, runnpm ci,npm testin server/, also lintfrontend-tests: setup Node, runnpm ci,npm testin client/ (if unit tests exist), alsonpm run buildto ensure build succeedse2e-tests: uses Cypress Docker image; runnpm run cy:runin 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.jsonfor backend (or use Dockerfile) -
Update:
client/.env.productionwith 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.examplewith 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.jsonif 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.jsonscripts:"build:zalo": "vite build --config vite.zalo.config.js" -
Create:
client/vite.zalo.config.jswith appropriate base and output dirdist/zalo -
Create:
client/zalo.config.json(Zalo Mini App manifest) (placeholder) -
Create:
client/src/platforms/zalo/adapter.jsto wrap Zalo SDK calls -
Step 1: Write failing test — none
-
Step 2: Create Zalo-specific Vite config that sets base to
./, outputs todist/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:zaloand verify output indist/zalo/ -
Step 7: Commit "feat: Zalo Mini App build configuration"
Post-Implementation: Local Development & QA
- Ensure
docker-compose upbrings 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.