Phase 1: Foundation & Server Setup¶
Overview¶
This phase establishes the project scaffolding, shared types/interfaces, the standalone WebSocket server process, authentication middleware, and build/deployment integration. At the end of this phase, the RMHbox server boots on port 7676, authenticates connections via Better Auth session tokens, and responds to health checks.
Prerequisites¶
Existing Next.js 16 / React 19 / TypeScript 5 codebase
PostgreSQL database with Better Auth tables
PM2 in production
Existing socket server on port 7001 for other games
1. Project Scaffolding¶
1.1 Create Directory Structure¶
Create
app/rmhbox/directoryCreate
app/rmhbox/layout.tsx(placeholder auth gate)Create
app/rmhbox/page.tsx(placeholder landing page)Create
app/rmhbox/[lobbyId]/page.tsx(placeholder lobby page)Create
app/api/rmhbox/directoryCreate
app/api/rmhbox/leaderboard/route.ts(stub)Create
app/api/rmhbox/stats/route.ts(stub)Create
app/api/rmhbox/history/route.ts(stub)Create
components/rmhbox/directoryCreate
components/rmhbox/minigames/directoryCreate
lib/rmhbox/directoryCreate
server/rmhbox/directoryCreate
server/rmhbox/minigames/directoryVerification: Run
ls -R/Get-ChildItem -Recurseon each directory to confirm all directories and stub files exist.
1.2 Install Dependencies¶
Install
rfc6902— JSON Patch generation and application for state diffsInstall
nanoid— compact room code generationInstall
zod— runtime schema validation for WebSocket payloads (may already exist; verify)Install
canvas-confetti— confetti animations for win screens (client-side)Install
fuse.js— fuzzy text matching for minigames (server-side)Verify
socket.ioandsocket.io-clientare already installed (v4.8+)Verify
zustandis already installed (v5+)Verify
howler/howler.jsis already installedVerify
lucide-reactis already installedVerification: Run
pnpm ls rfc6902 nanoid zod canvas-confetti fuse.js socket.io socket.io-client zustandand confirm all packages are listed with correct versions.
3. Constants & Configuration¶
3.1 Create lib/rmhbox/constants.ts¶
Define lobby constants:
ROOM_CODE_LENGTH = 6,ROOM_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789',DEFAULT_MAX_PLAYERS = 8,MIN_PLAYERS = 2,ABSOLUTE_MAX_PLAYERS = 16,DEFAULT_MAX_SPECTATORS = 20,MAX_SPECTATORS = 50,CHAT_MAX_LENGTH = 200,CHAT_HISTORY_LENGTH = 100Define timer constants:
HEARTBEAT_INTERVAL_MS = 10_000,LOBBY_IDLE_TIMEOUT_MS = 15 * 60 * 1000,LOBBY_ABSOLUTE_TIMEOUT_MS = 30 * 60 * 1000,LOBBY_EMPTY_TIMEOUT_MS = 2 * 60 * 1000,DISCONNECT_GRACE_PERIOD_MS = 120_000,VOTE_DURATION_SECONDS = 30,DEFAULT_INSTRUCTION_DURATION_SECONDS = 15,PRELOAD_TIMEOUT_MS = 30_000,COUNTDOWN_SECONDS = 3,RESULTS_DISPLAY_SECONDS = 10,LOBBY_GC_INTERVAL_MS = 60_000Define voting constant:
VOTE_CANDIDATE_COUNT = 5Define rate limit map:
SOCKET_RATE_LIMITSobject with per-event limitsVerification: Import constants in a test file and verify all values are correct.
3.2 Create lib/rmhbox/events.ts¶
Define all client-to-server event name constants (21 events):
LOBBY_CREATE,LOBBY_JOIN,LOBBY_LEAVE,LOBBY_KICK,LOBBY_TRANSFER_HOST,LOBBY_UPDATE_SETTINGS,LOBBY_END_SESSION,LOBBY_TOGGLE_READY,LOBBY_REQUEST_PROMOTION,LOBBY_CHAT,LOBBY_BROWSE,GAME_SELECT,GAME_START_VOTE,GAME_CAST_VOTE,GAME_FORCE_SKIP,GAME_READY_TO_RENDER,GAME_INPUT,LEADERBOARD_FETCHDefine all server-to-client event name constants (18 events):
LOBBY_CREATED,LOBBY_STATE_SNAPSHOT,LOBBY_BROWSE_RESULT,LOBBY_KICKED,LOBBY_DISBANDED,GAME_ACTION,GAME_INSTRUCTIONS,GAME_PRELOAD_START,GAME_PRELOAD_PROGRESS,GAME_COUNTDOWN,GAME_STARTED,GAME_STATE_SNAPSHOT,GAME_ROUND_RESULTS,GAME_SESSION_RESULTS,GAME_VOTE_STARTED,GAME_VOTE_UPDATE,GAME_VOTE_RESULT,LEADERBOARD_DATA,ERRORUse
rmhbox:prefix for all event names (e.g.,rmhbox:lobby:create)Verification: Log all event constants and confirm they match the design spec event catalog exactly.
3.3 Create server/rmhbox/config.ts¶
Define
configobject reading from environment variables with fallbacks:PORT:parseInt(process.env.RMHBOX_PORT || '7676')SOCKET_PATH:process.env.RMHBOX_SOCKET_PATH || '/rmhbox/'CORS_ORIGIN:process.env.RMHBOX_CORS_ORIGIN || process.env.SOCKET_CORS_ORIGIN || '*'HEARTBEAT_MS:parseInt(process.env.RMHBOX_HEARTBEAT_MS || '10000')GRACE_MS:parseInt(process.env.RMHBOX_GRACE_MS || '120000')GC_INTERVAL:parseInt(process.env.RMHBOX_GC_INTERVAL || '60000')SHUTDOWN_TIMEOUT:parseInt(process.env.RMHBOX_SHUTDOWN_TIMEOUT || '10000')MAX_HTTP_BUFFER_SIZE:1_000_000(1 MB)PING_INTERVAL_MS:25_000PING_TIMEOUT_MS:20_000
Export the config object as a typed constant
Verification: Log
configon startup and verify each field resolves correctly with both default and env-var-overridden values.
4. Zod Validation Schemas¶
4.2 Create server/rmhbox/schemas.ts (Server-Only Schemas)¶
Re-export all shared schemas from
lib/rmhbox/schemas.tsAdd the
validated()wrapper function that takes a Zod schema and a handler function, returns a function that validates the payload before calling the handler, emittingrmhbox:erroron validation failureVerification: Call
validated()with a test schema and confirm it correctly gates bad input.
6. Authentication Middleware¶
6.1 Create server/rmhbox/auth.ts¶
Import
pg(thepgpackage), create aPoolinstance usingprocess.env.DATABASE_URLImplement
validateSessionToken(token: string)function:Query
session+usertables:SELECT s.*, u.name as "userName", u.image as "avatarUrl" FROM session s JOIN "user" u ON s."userId" = u.id WHERE s.token = $1Return
nullif no row foundReturn
{ userId, userName, avatarUrl, expiresAt }if found
Implement
authMiddleware(socket, next)function:Extract
tokenfromsocket.handshake.auth?.tokenIf missing or non-string, call
next(new Error('AUTH_REQUIRED'))Call
validateSessionToken(token)If null, call
next(new Error('AUTH_FAILED'))If expired (
expiresAt < new Date()), callnext(new Error('SESSION_EXPIRED'))Attach
userId,userName,avatarUrl,sessionTokentosocket.dataCall
next()on success
Verification: Write integration tests:
Test with a valid session token → middleware calls
next()without error,socket.data.userIdis setTest with missing token → middleware calls
nextwithAUTH_REQUIREDerrorTest with invalid token → middleware calls
nextwithAUTH_FAILEDerrorTest with expired session → middleware calls
nextwithSESSION_EXPIREDerror
7. Standalone WebSocket Server Bootstrap¶
7.1 Create server/rmhbox/index.ts¶
Import
dotenv/configat top of fileImport
createServerfromhttpImport
Serverfromsocket.ioImport
configfrom./configImport
authMiddlewarefrom./authCreate HTTP server with
requestHandler:GET /healthreturns{ status: 'ok', uptime: process.uptime() }with 200All other routes return 404
Create Socket.io
Serverinstance with:path: config.SOCKET_PATHcors: { origin: config.CORS_ORIGIN, methods: ['GET', 'POST'], credentials: true }maxHttpBufferSize: config.MAX_HTTP_BUFFER_SIZEpingInterval: config.PING_INTERVAL_MSpingTimeout: config.PING_TIMEOUT_MS
Apply auth middleware globally:
io.use(authMiddleware)Set up
io.on('connection', (socket) => { ... })handler:Log connection with userId
Set up
socket.on('disconnect')handler that logs the reason
Implement graceful shutdown function:
On
SIGINTandSIGTERM: close io, close httpServer, exit afterconfig.SHUTDOWN_TIMEOUT
Call
httpServer.listen(config.PORT)with startup log messageVerification:
Start the server:
npx tsx server/rmhbox/index.tsConfirm startup log:
[RMHbox] WebSocket server running on http://localhost:7676curl http://localhost:7676/healthreturns{"status":"ok","uptime":...}curl http://localhost:7676/otherreturns 404Connect with Socket.io client without auth token → connection rejected with
AUTH_REQUIREDConnect with valid auth token → connection accepted,
socket.data.userIdpopulated
8. WebSocket Rate Limiting¶
8.1 Implement Per-Socket Rate Limiter¶
Create rate limiting utility in
server/rmhbox/rate-limit.tsImplement sliding window counter per socket per event type
Store counters in a
Map<string, { count: number; windowStart: number }>keyed by${socketId}:${eventName}Implement
checkRateLimit(socketId: string, eventName: string): boolean— returnstrueif within limits,falseif rate limitedClean up entries when sockets disconnect
Integrate with the
validated()wrapper so rate limiting is checked before schema validationVerification: Write a test that fires 6
rmhbox:lobby:createevents in rapid succession from the same socket — first 5 should succeed, 6th should be rejected withRATE_LIMITEDerror code.
9. Build & Deployment Integration¶
9.1 Update tsconfig.server.json¶
Add
server/rmhbox/**/*.tsto theincludearrayVerify
outDiris set todist-serverVerification: Run
tsc --project tsconfig.server.json— zero errors. Confirmdist-server/server/rmhbox/index.jsis generated.
9.2 Update package.json Scripts¶
Add
"rmhbox-server": "npx tsx server/rmhbox/index.ts"script for developmentUpdate
"dev"script to include RMHbox server:concurrently "next dev -p 7000" "pnpm run socket-server" "pnpm run rmhbox-server"Update
"start"script to include RMHbox server: add"node dist-server/server/rmhbox/index.js"to concurrently listVerification: Run
pnpm run rmhbox-server— server starts on port 7676. Runpnpm run dev— all three servers start (Next.js, socket server, RMHbox server).
9.3 Update deploy.sh¶
Add
APP_RMHBOX="rmhstudios-rmhbox"andPORT_RMHBOX=7676variablesAdd PM2 stop/delete for
$APP_RMHBOXinstop_apps()Add PM2 start command for
dist-server/server/rmhbox/index.jsinstart_apps()with--name "$APP_RMHBOX" --restart-delay=3000 --max-restarts=5Add
check_port "$PORT_RMHBOX"verificationVerification: Review the deploy script diff. In a staging environment, run the deploy script and confirm PM2 lists three processes (main app, socket server, RMHbox server).
9.5 Environment Variables¶
Document all required env vars:
DATABASE_URL(required, shared)RMHBOX_PORT(optional, default 7676)RMHBOX_CORS_ORIGIN(optional, default fromSOCKET_CORS_ORIGIN)NEXT_PUBLIC_RMHBOX_SOCKET_URL(required for client: production =https://rmhstudios.com, dev =http://localhost:7676)
Add
NEXT_PUBLIC_RMHBOX_SOCKET_URLto.env.example(or equivalent)Verification: Start the server with custom env vars (
RMHBOX_PORT=8888) and confirm it listens on the overridden port.
10. Game Registry Stub¶
10.1 Create lib/rmhbox/minigame-registry.ts¶
Define the
MINIGAME_REGISTRYas aRecord<string, MinigameDefinition>objectAdd placeholder entries for all 16 minigames with correct metadata:
rhyme-time: word, 2-10 players, 120s, no teams, spectate_onlyundercover-agent: word, 4-16 players, 300s, teams, spectate_onlycategory-crash: word, 3-12 players, 150s, no teams, join_next_subroundwiki-race: trivia, 2-10 players, 193s, no teams, spectate_onlyfact-or-friction: trivia, 2-16 players, 120s, no teams, join_next_subroundundercover-editor: creative, 4-10 players, 240s, no teams, spectate_onlyminimalist-masterpiece: creative, 3-12 players, 150s, no teams, spectate_onlyemoji-cinema: word, 3-12 players, 180s, no teams, join_next_subroundsequence-sam: action, 2-10 players, 120s, no teams, spectate_onlyhuman-keyboard: action, 3-10 players, 120s, teams (cooperative), spectate_onlycursor-curling: action, 2-8 players, 150s, no teams, spectate_onlyhuman-tetris: action, 4-10 players, 120s, teams (cooperative), spectate_onlyidentity-crisis: word, 3-10 players, 180s, no teams, spectate_onlyranking-file: trivia, 3-12 players, 120s, no teams, join_next_subroundpixel-pushers: action, 2-8 players, 120s, teams (cooperative), join_immediatelyscroll-soul: action, 2-16 players, 90s, no teams, spectate_only
Export
getEligibleMinigames(playerCount: number): MinigameDefinition[]function that filters by min/max player countVerification: Call
getEligibleMinigames(4)and confirm it returns all 16 games. CallgetEligibleMinigames(2)and confirm it excludes games withminPlayers > 2.
10.2 Create server/rmhbox/minigames/base-minigame.ts¶
Define abstract
BaseMinigameclass implementing the minigame interface:Constructor takes
MinigameContextAbstract methods:
start(),handleInput(userId, action, data),getStateForPlayer(userId),getStateForSpectator(),computeResults()Concrete methods:
handlePlayerDisconnect(userId),handlePlayerReconnect(userId),forceEnd(reason),cleanup()Helper methods:
setTimeout(fn, ms)(tracked),setInterval(fn, ms)(tracked)Track timers in
timers: NodeJS.Timeout[]andintervals: NodeJS.Timeout[]cleanup()clears all tracked timers/intervalsAll tracked callbacks wrapped in try-catch that calls
context.onError
Verification: Create a minimal test subclass that extends
BaseMinigame, implements all abstract methods as no-ops, callstart()/cleanup()— no errors.
Phase 1 Completion Criteria¶
All directories and stub files created
All npm dependencies installed
Shared types compile without errors
Constants, events, schemas defined and importable
server/rmhbox/index.tsboots a standalone Socket.io server on port 7676Authentication middleware validates Better Auth session tokens against PostgreSQL
Health check endpoint (
GET /health) responds correctlyRate limiting utility is functional
tsconfig.server.jsoncompiles all server code todist-server/package.jsonscripts updated for dev and productiondeploy.shupdated with PM2 config for RMHbox processMinigame registry populated with all 16 game stubs
BaseMinigameabstract class defined and extensible