Phase 4: Database Persistence, REST APIs, Client Architecture & UI¶
Overview¶
This phase implements the database schema (Prisma models for RMHbox profiles, matches, and match players), async match-end persistence, the leaderboard/stats/history REST APIs, the complete client-side architecture (Zustand store, Socket.io client wrapper, minigame component loader), all page scaffolding (landing page, lobby page), the shared UI shell and design system, sounds, and game registration. By the end of this phase, the platform is fully functional end-to-end — minus the actual minigames.
Prerequisites¶
Phases 1–3 complete (server with lobbies, game lifecycle, state sync, reconnection)
1. Database Schema¶
1.1 Add Prisma Models to prisma/schema.prisma¶
Add
RMHboxProfilemodel:id: String @id @default(cuid())userId: String @uniqueuser: User @relation(fields: [userId], references: [id], onDelete: Cascade)totalGamesPlayed: Int @default(0)totalWins: Int @default(0)totalScore: Int @default(0)totalPlayTimeMs: Int @default(0)minigameStats: Json @default("{}")currentWinStreak: Int @default(0)bestWinStreak: Int @default(0)createdAt: DateTime @default(now())updatedAt: DateTime @updatedAtmatches: RMHboxMatchPlayer[]Index on
totalWinsdescending, index ontotalScoredescending@@map("rmhbox_profile")
Add
RMHboxMatchmodel:id: String @id @default(cuid())minigameId: StringlobbyId: StringstartedAt: DateTime @default(now())endedAt: DateTime?durationMs: Int?winnerUserId: String?playerCount: IntgameLog: Json?results: Jsonplayers: RMHboxMatchPlayer[]Indexes on
minigameId,startedAt(sort: Desc),lobbyId@@map("rmhbox_match")
Add
RMHboxMatchPlayermodel:id: String @id @default(cuid())matchId: String+ relation to RMHboxMatch (onDelete: Cascade)profileId: String+ relation to RMHboxProfile (onDelete: Cascade)userId: StringuserName: Stringrank: Intscore: IntwasWinner: Boolean @default(false)stats: Json @default("{}")createdAt: DateTime @default(now())@@unique([matchId, userId])Indexes on
profileId,userId,createdAt(sort: Desc)@@map("rmhbox_match_player")
Add
rmhboxProfile: RMHboxProfile?relation to the existingUsermodelVerification: Run
pnpm prisma validate— schema is valid, no errors.
1.2 Run Database Migration¶
Generate and run migration:
pnpm prisma migrate dev --name add-rmhbox-modelsVerify the migration SQL creates the three tables with all columns, indexes, and constraints
Regenerate Prisma client:
pnpm prisma generateVerification: Run
pnpm prisma db push --dry-runor inspect the migration file to confirm all tables, columns, indexes, and foreign keys are present. Query the database to list new tables.
2. Match-End Persistence¶
2.1 Create server/rmhbox/leaderboard.ts¶
Define
LeaderboardServiceclassImport Prisma client (use
pg.Poolfor direct queries since this is the standalone server — no Prisma client in the standalone process context)Alternative: If Prisma client can be imported in the standalone server (needs
@prisma/clientin the dep tree), use that. If not, use raw SQL viapg.Pool.Recommended approach: Create a separate Prisma client instance in the standalone server (
new PrismaClient())
Verification: Verify database connection works from the standalone server process.
2.2 Implement persistMatchResults()¶
Accept parameters:
lobbyId,minigameId,results: MinigameResults,players: Map<string, RMHboxPlayer>,gameLog: GameLog | nullWrap entire function body in try-catch — errors are logged but NEVER thrown (fire-and-forget)
Step 1: Create
RMHboxMatchrecord withminigameId,lobbyId,startedAt,endedAt,durationMs,winnerUserId,playerCount,gameLog,resultsStep 2: For each player ranking:
Upsert
RMHboxProfile: create if not exists, update with incremented statsUse read-modify-write for
minigameStatsJSON field:Read existing stats, merge new game-specific stats from
ranking.deltasUpdate
gamesPlayed,wins,bestScore,totalScore,averageRank
Update win streak: if rank === 1, increment
currentWinStreakand updatebestWinStreak; else resetcurrentWinStreakto 0Create
RMHboxMatchPlayerrecord withmatchId,profileId,userId,userName,rank,score,wasWinner,stats
Log success:
[RMHbox] Match ${matchId} persisted (${minigameId}, ${count} players)Verification: Run a test game to completion → check database:
rmhbox_matchtable has 1 new row with correct minigameId and resultsrmhbox_profiletable has entries for each player with updated statsrmhbox_match_playertable has entries linking players to matchWin streaks are calculated correctly
2.3 Integrate Persistence into Game Coordinator¶
In
handleGameComplete()(from Phase 3):After broadcasting round results, call
leaderboardService.persistMatchResults(...)WITHOUT awaiting (fire-and-forget)Catch errors and log them — never let persistence failures affect the game flow
Verification: Complete a game → results are broadcast immediately, database write happens asynchronously, no delay for players.
3. REST API Endpoints¶
3.1 Create app/api/rmhbox/leaderboard/route.ts¶
Implement
GEThandlerParse query parameters:
period(‘all-time’ | ‘weekly’ | ‘monthly’),minigame(optional string),metric(‘score’ | ‘wins’ | ‘games’),limit(default 20, max 50),offset(default 0)Apply rate limiting: 30 requests per 60 seconds (using existing
rateLimit()fromlib/rate-limit.ts)Query logic:
All-time, global: Query
RMHboxProfileordered bytotalScore,totalWins, ortotalGamesPlayedWeekly/monthly: Query
RMHboxMatchPlayerwith date filter oncreatedAt, aggregate by userIdPer-minigame: Filter
RMHboxMatchbyminigameId, join withRMHboxMatchPlayer
Return
LeaderboardResponse:entries[],total,period,minigame,metric,userRank(requesting user’s rank if authenticated)To compute
userRank: get the authenticated user’s session, find their position in the leaderboardVerification: Call the endpoint with various parameter combinations:
GET /api/rmhbox/leaderboard?period=all-time&metric=score→ ordered by scoreGET /api/rmhbox/leaderboard?period=weekly&metric=wins→ filtered to current weekGET /api/rmhbox/leaderboard?minigame=rhyme-time→ filtered to one gameExceeding rate limit → 429 response
3.2 Create app/api/rmhbox/stats/route.ts¶
Implement
GEThandlerParse query parameter:
userId(required)Apply rate limiting: 20 requests per 60 seconds
Query
RMHboxProfileby userIdQuery last 10
RMHboxMatchPlayerrecords for this user, joined withRMHboxMatchCompute derived stats:
winRate(totalWins / totalGamesPlayed * 100),favoriteMinigame(most-played from minigameStats)Return
PlayerStatsResponse:global(aggregate stats),minigames(per-game stats map),recentMatches[]Verification: Call with a valid userId → returns correctly aggregated stats. Call with nonexistent userId → returns empty/default stats or 404.
3.3 Create app/api/rmhbox/history/route.ts¶
Implement
GEThandlerParse query parameters:
matchId(optional),userId(optional),minigame(optional),limit,offsetApply rate limiting: 20 requests per 60 seconds
If
matchIdis provided → return single match detail with fullgameLog(if available)If
userIdis provided → return paginated list of that user’s matchesIf
minigameis provided → filter by minigameIdReturn
MatchHistoryResponse(list) orMatchDetailResponse(single)Verification: Call with
matchId→ returns full match detail. Call withuserId→ returns paginated match list with correct order.
3.4 WebSocket Leaderboard Handler¶
In
leaderboardService.handleConnection(socket):Register
rmhbox:leaderboard:fetchlistenerQuery leaderboard data and emit
rmhbox:leaderboard:datato the requesting socket
Verification: Emit leaderboard fetch via WebSocket → receive leaderboard data response.
4. Client-Side Store¶
4.1 Create lib/rmhbox/store.ts¶
Import
createfromzustandandpersistfromzustand/middlewareDefine
RMHboxUserSettingsinterface:masterVolume: number(0–1, default 0.7)sfxVolume: number(0–1, default 0.8)musicVolume: number(0–1, default 0.5)showChat: boolean(default true)chatPosition: 'left' | 'right'(default ‘right’)
Define
RMHboxStoreinterface:connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'lobby: ClientLobbyState | nullgameState: Record<string, unknown>lastSeq: numbersettings: RMHboxUserSettingsAction methods:
setConnectionStatus,applyAction,applyFullSync,setGameState,updateSettings,reset
Implement
applyAction():Check
action.seq > lastSeq— skip if not newerApply via
applyLobbyAction()andapplyGameAction()reducer functionsUpdate
lastSeq
Implement
applyFullSync(fullState):Replace
lobbyentirely, updatelastSeq
Use
persistmiddleware forsettingsonly (saved to localStorage asrmhbox-settings)Use
partializeto only persist thesettingsfield
Verification: Create the store, apply an action with seq=1 → state updates. Apply action with seq=0 → ignored. Apply full sync → state replaced.
4.2 Implement Reducer Functions¶
Create
applyLobbyAction(lobby, action)function:Handle
PLAYER_JOINED: add to players arrayHandle
PLAYER_LEFT: remove from players arrayHandle
PLAYER_KICKED: remove from players arrayHandle
SPECTATOR_JOINED: add to spectators arrayHandle
SPECTATOR_LEFT: remove from spectators arrayHandle
SPECTATOR_PROMOTED: move from spectators to playersHandle
HOST_TRANSFERRED: update hostUserIdHandle
SETTINGS_UPDATED: merge new settingsHandle
PLAYER_READY_CHANGED: update player’s isReadyHandle
STATE_CHANGED: update lobby stateHandle
CHAT_MESSAGE: append to chat arrayHandle
PLAYER_CONNECTED: update player’s isConnectedHandle
PLAYER_DISCONNECTED: update player’s isConnectedHandle
VOTE_STARTED,VOTE_CAST,VOTE_RESULT: update voting stateHandle
GAME_SELECTED: update currentGame
Create
applyGameAction(gameState, action)function:Forward game-specific actions to the appropriate handler based on minigame
Store raw payloads in gameState for the minigame component to consume
Verification: Dispatch each lobby action type and confirm the store state is correct after each.
5. Socket Client Wrapper¶
5.1 Create lib/rmhbox/socket.ts¶
Implement
connectToRMHbox(): Promise<Socket>:Get session token from
authClient.getSession()If no token, throw
'Not authenticated'Create Socket.io connection to
process.env.NEXT_PUBLIC_RMHBOX_SOCKET_URL || 'http://localhost:7676'Options:
path: '/rmhbox/',auth: { token },reconnection: true,reconnectionAttempts: 10,reconnectionDelay: 1000,reconnectionDelayMax: 10000,timeout: 10000Update store’s connectionStatus
Set up global listeners:
connect: status → ‘connected’, reset reconnect attemptsdisconnect: status → ‘connecting’ (or ‘error’ if server-forced)connect_error: increment attempts, set ‘error’ if maxed outrmhbox:lobby:state_snapshot:store.applyFullSync()rmhbox:game:action:store.applyAction()rmhbox:game:state_snapshot:store.setGameState()rmhbox:error: log and surface to UI
Return the socket
Implement
getSocket(): Socket | nullImplement
disconnectFromRMHbox(): void— disconnect, null socket, reset storeImplement helper
emit(event, data)— wrapssocket.emit()with null checkVerification: Call
connectToRMHbox()with valid auth → socket connects, status is ‘connected’. Call without auth → throws error.
6. Page Scaffolding¶
6.1 Create app/rmhbox/layout.tsx¶
Import
authfrom@/lib/authCheck session server-side:
const session = await auth.api.getSession({ headers: await headers() })If no session, redirect to
/login?callbackURL=/rmhboxRender children wrapped in appropriate providers
Verification: Visit
/rmhboxwhile logged out → redirected to login. Visit while logged in → page renders.
6.2 Create app/rmhbox/page.tsx¶
Create
RMHboxLandingcomponent (client component)Three main sections:
Create Lobby button — opens settings dialog then calls
socket.emit('rmhbox:lobby:create')Join Lobby input — text field for 6-char room code, button to join
Browse Public Lobbies — calls
socket.emit('rmhbox:lobby:browse'), renders resultsLeaderboard panel — fetches from
/api/rmhbox/leaderboard
On lobby creation/join success: redirect to
/rmhbox/{lobbyId}Connect to RMHbox WebSocket on mount via
connectToRMHbox()Verification: Landing page renders with all sections. Create a lobby → navigated to lobby page. Join by code → navigated to lobby page. Browse shows public lobbies.
6.3 Create app/rmhbox/[lobbyId]/page.tsx¶
Create
LobbyPagecomponent (client component)On mount: connect to WebSocket (if not connected), join the lobby
Read lobby state from Zustand store
Render based on
lobby.state:WAITING: render<LobbyView>(waiting room)VOTING: render<GameVoting>INSTRUCTIONS: render<InstructionsScreen>PRELOADING: render<PreloadScreen>COUNTDOWN: render countdown overlayPLAYING: render<MinigameRenderer>ROUND_RESULTS: render<ResultsScreen>SESSION_RESULTS: render session results
Show
<SpectatorBanner>ifmyRole === 'spectator'Verification: Navigate to
/rmhbox/ABCDEFwith a valid lobby → correct state-based view renders. Lobby state changes → UI transitions smoothly.
7. Core UI Components¶
7.1 Create components/rmhbox/LobbyView.tsx¶
Display room code prominently (large, monospace, with copy button)
Player list with avatars, names, ready status indicators, host crown icon
Host controls panel (visible only to host): Start Game, Start Vote, Settings gear, End Session
Ready button with pulse animation (not ready) / solid green (ready)
Chat panel (desktop: sidebar, mobile: overlay/bottom sheet)
“Invite Link” share button (copies join URL)
Verification: Lobby view renders all elements. Ready button toggles. Host sees controls, non-host doesn’t.
7.2 Create components/rmhbox/GameVoting.tsx¶
Display 5 candidate minigames as cards with icon, name, description, category badge
Each card is clickable to cast a vote
Show vote tally per candidate (bar or count)
Timer countdown (30 seconds)
Highlight the player’s current vote
Show “Waiting for votes…” with voter count
Verification: Voting screen renders candidates. Clicking a card emits a vote. Tallies update in real-time.
7.3 Create components/rmhbox/InstructionsScreen.tsx¶
Display minigame title, icon, description
Bullet-point rules
Platform-specific control hints (desktop vs mobile)
Estimated duration
Timer countdown (15 seconds or custom)
Host skip button
Verification: Instructions show correctly for a given minigame. Timer counts down. Host can skip.
7.4 Create components/rmhbox/PreloadScreen.tsx¶
Show loading progress bar
List players with check/loading indicators for who is ready
“Waiting for: PlayerName, PlayerName…” text
Auto-emit
ready_to_renderwhen assets are loaded (or immediately if no assets needed)Verification: Preload screen shows, emits ready_to_render. When all ready, transitions to countdown.
7.5 Create components/rmhbox/ResultsScreen.tsx¶
Display player rankings (1st, 2nd, 3rd with podium styling)
Score breakdowns per player (deltas)
Awards section with icons and descriptions
Session standings (cumulative leaderboard)
Staggered entrance animations (Framer Motion)
Confetti for the winner (
canvas-confetti)Verification: Results screen renders with correct rankings. Confetti fires for winner. Animations play.
7.6 Create components/rmhbox/minigames/MinigameRenderer.tsx¶
Import React
lazyandSuspenseDefine
MINIGAME_COMPONENTSmap:Record<string, React.LazyExoticComponent<React.FC>>Add lazy imports for all 16 minigames (pointing to their game components — stubs for now)
Render the correct component based on
lobby.currentGame.minigameIdShow loading fallback while component loads
Show error fallback for unknown minigame IDs
Verification: Set
currentGame.minigameIdto a known game → correct component lazy-loads. Set to unknown → error fallback shows.
8. UI Design System¶
8.1 Add CSS Custom Properties to app/globals.css (or RMHbox-specific CSS)¶
Add RMHbox color palette:
--rmhbox-bg: #0f0f1a,--rmhbox-surface: #1a1a2e,--rmhbox-surface-hover: #252540--rmhbox-border: #2a2a4a,--rmhbox-text: #e0e0f0,--rmhbox-text-muted: #8888aa--rmhbox-accent: #7c5cfc,--rmhbox-accent-hover: #9b7eff--rmhbox-success: #4ade80,--rmhbox-danger: #f87171,--rmhbox-warning: #fbbf24,--rmhbox-info: #60a5fa
Add RMHbox typography:
--rmhbox-font-display: 'Nunito', 'Segoe UI', sans-serif--rmhbox-font-body: 'Inter', 'Segoe UI', sans-serif--rmhbox-font-mono: 'JetBrains Mono', monospace
Add design tokens for spacing and border radius
Import Google Fonts: Nunito (for display), Inter (existing or body), JetBrains Mono (codes/scores)
Verification: Apply RMHbox classes to test elements — colors, fonts, spacing render correctly.
9. Sound System¶
9.1 Create lib/rmhbox/audio.ts¶
Import Howler.js
Define sound effects:
chime(player join),click(button press),countdownBeep(3-2-1),goFanfare(game start),scoreDing(points),buzzer(wrong answer),victoryFanfare(game complete),swoosh(transitions)Create a
SoundManagerclass or function that:Loads sound files from
/public/music/rmhbox/(or similar)Respects volume settings from Zustand store (
masterVolume,sfxVolume)Provides
play(soundName)method
Verification: Call
play('click')— sound plays at correct volume. Update volume setting — subsequent sounds respect it.
10. Game Registration¶
10.1 Update lib/games.ts¶
Add RMHbox entry to the games array:
id: 'rmhbox',title: 'RMHbox',description: 'Party game madness! Join a lobby and play 16+ minigames with friends.'href: '/rmhbox',status: 'Playable',cta: 'Play Now'gradient: 'from-purple-500 to-pink-500',iconName: 'Gamepad2',color: '#7c5cfc'tags: ['multiplayer', 'party', 'minigames']
Verification: RMHbox appears on the homepage games list with correct styling and links to
/rmhbox.
11. Integration Testing¶
11.1 Full End-to-End Flow¶
Open the site → navigate to /rmhbox → landing page loads
Create a lobby → redirected to lobby page with room code displayed
Share room code → second player joins → both see each other in the player list
Host starts a vote → candidates appear, both can vote
Game runs (stub) → results display with scores and rankings
Check database → match record, profiles, and match-player records exist
View leaderboard on landing page → shows the player who just played
Disconnect and reconnect → state is fully restored
Verification: All assertions pass. Complete flow works from landing to results to persistence.
11.2 API Tests¶
Hit
/api/rmhbox/leaderboard→ returns valid JSON with entriesHit
/api/rmhbox/stats?userId=xxx→ returns player statsHit
/api/rmhbox/history?userId=xxx→ returns match historyHit
/api/rmhbox/history?matchId=xxx→ returns detailed match with gameLogVerification: All endpoints return correct data shapes.
Phase 4 Completion Criteria¶
Prisma schema includes RMHboxProfile, RMHboxMatch, RMHboxMatchPlayer models
Migration runs successfully, tables exist with correct indexes
Match results are persisted asynchronously after game completion
Win streaks and per-minigame stats are computed correctly
Leaderboard API supports all-time, weekly, monthly, per-minigame, by multiple metrics
Stats API returns aggregated player statistics
History API supports listing and detail views
All API endpoints are rate-limited
Zustand store correctly applies actions and full syncs
Action reducers handle all lobby and game action types
Socket client wrapper connects, reconnects, and manages connection state
Landing page allows creating, joining, and browsing lobbies
Lobby page renders all lifecycle states with correct components
All shared UI components render correctly
Design system (colors, typography, tokens) is applied
Game shell wrapper provides consistent header/footer/layout
Sound effects play at correct times with user volume control
RMHbox appears in the site’s game registry
Full end-to-end flow works from landing to results to database persistence