- Changed CreateSessionOptions.activePlayers from number[] to string[] - Updated socket-server.ts fallback from [1] to [data.userId] - Added debug logging to validateFlipCard to diagnose turn validation issues This ensures that when a session is created without explicit activePlayers, it uses the actual UUID of the requesting player instead of the numeric value 1.
35 KiB
Arcade Session Implementation Plan
Overview
Implement modal arcade session state that persists across tabs and devices, ensuring users return to their active game with full state synchronization.
Key Requirements
- WebSocket-based real-time sync managed in Next.js server
- TTL-based automatic cleanup (24-hour default, configurable)
- Auto-resume: Users taken directly to active game with exact state
- Exit button: Prominent in mini nav (reloading/navigating returns to game until explicit exit)
- Route migration: Move games from
/games/{name}to/arcade/{name} - Cross-device sync: Same session across all devices for same account (guest or permanent)
- Server-side validation: All game moves validated server-side with optimistic client updates
- Rollback UX: User-friendly feedback when server rejects moves
Architecture Overview
┌─────────────┐ ┌─────────────┐
│ Client A │ │ Client B │
│ (Desktop) │ │ (Mobile) │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. Make move (optimistic UI) │
├──────────────────────────────────┤
│ │
│ 2. WebSocket: Send move │
├─────────────┐ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Next.js WS │ │
│ │ Server │ │
│ │ Validates │ │
│ │ + Persists │ │
│ └──────┬──────┘ │
│ │ │
│ 3a. Accepted│ │
│◄────────────┤ │
│ │ 3b. Broadcast │
│ ├────────────────────►│
│ │ │ 4. Sync UI
│ │ │
│ OR │
│ 3a. Rejected (+ server state) │
│◄────────────┤ │
│ 4. Rollback + Toast │
Phase 1: Database Schema
New Table: arcade_sessions
// apps/web/src/db/schema/arcade-sessions.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { users } from './users'
export const arcadeSessions = sqliteTable('arcade_sessions', {
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
// Session metadata
currentGame: text('current_game', {
enum: ['memory-lightning', 'battle-arena', 'complement-race', 'master-organizer']
}).notNull(),
gameUrl: text('game_url').notNull(), // e.g., '/arcade/matching'
// Game state (JSON blob)
gameState: text('game_state', { mode: 'json' }).notNull(),
// Active players snapshot (for quick access)
activePlayers: text('active_players', { mode: 'json' }).notNull(),
// Timing & TTL
startedAt: integer('started_at', { mode: 'timestamp' }).notNull(),
lastActivityAt: integer('last_activity_at', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), // TTL-based
// Status
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
// Version for optimistic locking
version: integer('version').notNull().default(1),
})
export type ArcadeSession = typeof arcadeSessions.$inferSelect
export type NewArcadeSession = typeof arcadeSessions.$inferInsert
Migration
pnpm drizzle-kit generate:sqlite
pnpm drizzle-kit migrate
Phase 2: WebSocket Server
Dependencies
pnpm add socket.io socket.io-client
Custom Server Setup
File: apps/web/server.js
const { createServer } = require('http')
const { parse } = require('url')
const next = require('next')
const { initializeSocketServer } = require('./src/lib/socket-server')
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true)
handle(req, res, parsedUrl)
})
// Initialize Socket.IO
initializeSocketServer(server)
const PORT = process.env.PORT || 3000
server.listen(PORT, () => {
console.log(`> Ready on http://localhost:${PORT}`)
})
})
Update package.json:
{
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}
}
Socket Server Implementation
File: apps/web/src/lib/socket-server.ts
import { Server as SocketIOServer } from 'socket.io'
import { Server as HTTPServer } from 'http'
import { getGameValidator } from './game-validation'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
export function initializeSocketServer(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true,
},
})
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id)
// Join user's personal room
socket.on('join-arcade-session', async ({ userId }) => {
socket.join(`arcade:${userId}`)
console.log(`👤 User ${userId} joined their arcade room`)
// Send current session state
const session = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, userId),
})
if (session?.isActive) {
socket.emit('session-state', session)
}
})
// Handle game moves with validation
socket.on('game-move', async ({ userId, gameType, move, clientVersion }) => {
try {
// 1. Fetch current server state
const session = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, userId),
})
if (!session?.isActive) {
socket.emit('move-rejected', {
error: 'No active session',
move
})
return
}
// 2. Check version (optimistic locking)
if (session.version !== clientVersion) {
socket.emit('move-rejected', {
error: 'State out of sync',
serverState: JSON.parse(session.gameState),
serverVersion: session.version,
move,
})
return
}
const currentState = JSON.parse(session.gameState)
// 3. Validate move
const validator = getGameValidator(gameType)
const validation = validator.validateMove(currentState, move)
if (!validation.valid) {
socket.emit('move-rejected', {
error: validation.error,
move,
serverState: currentState,
serverVersion: session.version,
})
console.warn(`❌ Move rejected for ${userId}:`, validation.error)
return
}
// 4. Apply validated move
const newState = validation.updatedState!
const newVersion = session.version + 1
// 5. Save to database
await db
.update(schema.arcadeSessions)
.set({
gameState: JSON.stringify(newState),
lastActivityAt: new Date(),
expiresAt: calculateExpiresAt(24), // Reset TTL
version: newVersion,
})
.where(eq(schema.arcadeSessions.userId, userId))
// 6. Acknowledge to sender
socket.emit('move-accepted', {
move,
version: newVersion
})
// 7. Broadcast to other devices
socket.to(`arcade:${userId}`).emit('game-state-updated', {
state: newState,
move,
version: newVersion,
})
console.log(`✅ Move accepted for ${userId}:`, move.type)
} catch (error) {
console.error('Error processing move:', error)
socket.emit('move-rejected', {
error: 'Server error',
move
})
}
})
// Handle arcade exit
socket.on('exit-arcade', async ({ userId }) => {
await db
.update(schema.arcadeSessions)
.set({ isActive: false })
.where(eq(schema.arcadeSessions.userId, userId))
io.to(`arcade:${userId}`).emit('arcade-exited')
console.log(`🚪 User ${userId} exited arcade`)
})
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id)
})
})
return io
}
function calculateExpiresAt(ttlHours: number = 24): Date {
return new Date(Date.now() + ttlHours * 60 * 60 * 1000)
}
Phase 3: Shared Game Validation (Isomorphic)
Validation Types
File: apps/web/src/lib/game-validation/types.ts
export interface GameMove {
type: string // 'FLIP_CARD' | 'MATCH_FOUND' | etc.
playerId: string
timestamp: number
data: any
}
export interface ValidationResult {
valid: boolean
error?: string
updatedState?: any
}
export interface GameValidator {
validateMove(currentState: any, move: GameMove): ValidationResult
applyMove(currentState: any, move: GameMove): any
}
Example: Matching Game Validator
File: apps/web/src/lib/game-validation/matching-validator.ts
import { GameValidator, GameMove, ValidationResult } from './types'
import { MemoryPairsState } from '@/app/arcade/matching/context/types'
export class MatchingGameValidator implements GameValidator {
validateMove(currentState: MemoryPairsState, move: GameMove): ValidationResult {
switch (move.type) {
case 'FLIP_CARD': {
const { cardId } = move.data
const card = currentState.cards.find(c => c.id === cardId)
if (!card) {
return { valid: false, error: 'Card not found' }
}
if (card.matched) {
return { valid: false, error: 'Card already matched' }
}
if (currentState.flippedCards.length >= 2) {
return { valid: false, error: 'Too many cards flipped' }
}
if (currentState.currentPlayer !== move.playerId) {
return { valid: false, error: 'Not your turn' }
}
const updatedState = this.applyMove(currentState, move)
return { valid: true, updatedState }
}
case 'MATCH_FOUND': {
const { cardIds } = move.data
const flippedIds = currentState.flippedCards.map(c => c.id)
if (!cardIds.every((id: string) => flippedIds.includes(id))) {
return { valid: false, error: 'Cards not flipped' }
}
const [card1, card2] = cardIds.map((id: string) =>
currentState.cards.find(c => c.id === id)
)
if (!this.isMatch(card1, card2, currentState.gameType)) {
return { valid: false, error: 'Cards do not match' }
}
const updatedState = this.applyMove(currentState, move)
return { valid: true, updatedState }
}
default:
return { valid: false, error: 'Unknown move type' }
}
}
applyMove(currentState: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
case 'FLIP_CARD':
const card = currentState.cards.find(c => c.id === move.data.cardId)!
return {
...currentState,
flippedCards: [...currentState.flippedCards, card],
}
case 'MATCH_FOUND':
return {
...currentState,
matchedPairs: currentState.matchedPairs + 1,
scores: {
...currentState.scores,
[move.playerId]: (currentState.scores[move.playerId] || 0) + 1,
},
flippedCards: [],
}
default:
return currentState
}
}
private isMatch(card1: any, card2: any, gameType: string): boolean {
if (gameType === 'abacus-numeral') {
return card1.number === card2.number && card1.type !== card2.type
}
if (gameType === 'complement-pairs') {
return card1.number + card2.number === card1.targetSum
}
return false
}
}
Validator Registry
File: apps/web/src/lib/game-validation/index.ts
import { MatchingGameValidator } from './matching-validator'
import { GameValidator } from './types'
export function getGameValidator(gameType: string): GameValidator {
switch (gameType) {
case 'battle-arena':
return new MatchingGameValidator()
case 'memory-lightning':
// TODO: Implement MemoryQuizValidator
throw new Error('Memory quiz validator not implemented')
case 'complement-race':
// TODO: Implement ComplementRaceValidator
throw new Error('Complement race validator not implemented')
default:
throw new Error(`Unknown game type: ${gameType}`)
}
}
Phase 4: Client WebSocket Integration
WebSocket Hook
File: apps/web/src/hooks/useArcadeSocket.ts
import { useEffect, useState } from 'react'
import { io, Socket } from 'socket.io-client'
let socket: Socket | null = null
export function useArcadeSocket() {
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
if (!socket) {
socket = io({
path: '/api/socket',
autoConnect: true,
})
socket.on('connect', () => {
console.log('✅ WebSocket connected')
setIsConnected(true)
})
socket.on('disconnect', () => {
console.log('❌ WebSocket disconnected')
setIsConnected(false)
})
}
return () => {
// Don't disconnect - keep socket alive across re-renders
}
}, [])
return { socket, isConnected }
}
Optimistic Game State Hook
File: apps/web/src/hooks/useOptimisticGameState.ts
import { useReducer, useEffect, useCallback } from 'react'
import { useArcadeSocket } from './useArcadeSocket'
import { GameMove } from '@/lib/game-validation/types'
import { getGameValidator } from '@/lib/game-validation'
import { showMoveRejectedToast } from '@/components/MoveRejectedToast'
interface OptimisticState<T> {
committed: T
optimistic: T
pendingMoves: GameMove[]
version: number
}
type Action<T> =
| { type: 'APPLY_OPTIMISTIC'; move: GameMove; gameType: string }
| { type: 'COMMIT_MOVE'; move: GameMove; version: number }
| { type: 'ROLLBACK_MOVE'; move: GameMove; serverState: T; version: number }
| { type: 'SYNC_SERVER_STATE'; state: T; version: number }
function optimisticReducer<T>(
state: OptimisticState<T>,
action: Action<T>
): OptimisticState<T> {
switch (action.type) {
case 'APPLY_OPTIMISTIC': {
const validator = getGameValidator(action.gameType)
const result = validator.applyMove(state.optimistic, action.move)
return {
...state,
optimistic: result,
pendingMoves: [...state.pendingMoves, action.move],
}
}
case 'COMMIT_MOVE':
return {
...state,
committed: state.optimistic,
pendingMoves: state.pendingMoves.filter(
m => m.timestamp !== action.move.timestamp
),
version: action.version,
}
case 'ROLLBACK_MOVE':
return {
committed: action.serverState,
optimistic: action.serverState,
pendingMoves: [],
version: action.version,
}
case 'SYNC_SERVER_STATE':
if (state.pendingMoves.length === 0) {
return {
committed: action.state,
optimistic: action.state,
pendingMoves: [],
version: action.version,
}
}
return {
...state,
committed: action.state,
version: action.version,
}
default:
return state
}
}
export function useOptimisticGameState<T>(
initialState: T,
gameType: string,
userId: string
) {
const { socket } = useArcadeSocket()
const [state, dispatch] = useReducer<
React.Reducer<OptimisticState<T>, Action<T>>
>(optimisticReducer, {
committed: initialState,
optimistic: initialState,
pendingMoves: [],
version: 0,
})
const applyMove = useCallback((move: GameMove) => {
dispatch({ type: 'APPLY_OPTIMISTIC', move, gameType })
socket?.emit('game-move', {
userId,
gameType,
move,
clientVersion: state.version,
})
}, [socket, userId, gameType, state.version])
useEffect(() => {
if (!socket) return
socket.on('move-accepted', ({ move, version }) => {
console.log('✅ Move accepted:', move.type)
dispatch({ type: 'COMMIT_MOVE', move, version })
})
socket.on('move-rejected', ({ error, move, serverState, serverVersion }) => {
console.warn('❌ Move rejected:', error)
showMoveRejectedToast(error)
dispatch({
type: 'ROLLBACK_MOVE',
move,
serverState,
version: serverVersion,
})
})
socket.on('game-state-updated', ({ state: newState, version }) => {
console.log('🔄 State synced from another device')
dispatch({
type: 'SYNC_SERVER_STATE',
state: newState,
version
})
})
return () => {
socket.off('move-accepted')
socket.off('move-rejected')
socket.off('game-state-updated')
}
}, [socket])
return {
state: state.optimistic,
committedState: state.committed,
applyMove,
isPending: state.pendingMoves.length > 0,
version: state.version,
}
}
Phase 5: API Routes
GET /api/arcade-session
File: apps/web/src/app/api/arcade-session/route.ts
import { NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
import { getViewerId } from '@/lib/viewer'
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ session: null })
}
const session = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, user.id),
})
if (!session?.isActive) {
return NextResponse.json({ session: null })
}
return NextResponse.json({ session })
} catch (error) {
console.error('Failed to fetch arcade session:', error)
return NextResponse.json(
{ error: 'Failed to fetch session' },
{ status: 500 }
)
}
}
POST /api/arcade-session
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const now = new Date()
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000)
const [session] = await db
.insert(schema.arcadeSessions)
.values({
userId: user.id,
currentGame: body.currentGame,
gameUrl: body.gameUrl,
gameState: JSON.stringify(body.gameState),
activePlayers: JSON.stringify(body.activePlayers),
startedAt: now,
lastActivityAt: now,
expiresAt,
isActive: true,
version: 1,
})
.onConflictDoUpdate({
target: schema.arcadeSessions.userId,
set: {
currentGame: body.currentGame,
gameUrl: body.gameUrl,
gameState: JSON.stringify(body.gameState),
activePlayers: JSON.stringify(body.activePlayers),
lastActivityAt: now,
expiresAt,
isActive: true,
version: sql`${schema.arcadeSessions.version} + 1`,
},
})
.returning()
return NextResponse.json({ session })
} catch (error) {
console.error('Failed to create/update session:', error)
return NextResponse.json(
{ error: 'Failed to save session' },
{ status: 500 }
)
}
}
DELETE /api/arcade-session
export async function DELETE() {
try {
const viewerId = await getViewerId()
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
await db
.update(schema.arcadeSessions)
.set({ isActive: false })
.where(eq(schema.arcadeSessions.userId, user.id))
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to end session:', error)
return NextResponse.json(
{ error: 'Failed to end session' },
{ status: 500 }
)
}
}
Phase 6: Navigation Guard & Auto-Resume
Arcade Guard Hook
File: apps/web/src/hooks/useArcadeGuard.ts
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useArcadeSocket } from './useArcadeSocket'
export function useArcadeGuard() {
const router = useRouter()
const { socket } = useArcadeSocket()
const { data: sessionData, isLoading } = useQuery({
queryKey: ['arcadeSession'],
queryFn: async () => {
const res = await fetch('/api/arcade-session')
if (!res.ok) return null
const data = await res.json()
return data.session
},
})
useEffect(() => {
if (isLoading || !sessionData) return
const currentPath = window.location.pathname
// If there's an active session and we're not already there
if (sessionData.isActive && sessionData.gameUrl !== currentPath) {
console.log('🎮 Resuming active game:', sessionData.gameUrl)
router.replace(sessionData.gameUrl)
}
// Join socket room for this session
if (socket) {
socket.emit('join-arcade-session', {
userId: sessionData.userId
})
}
}, [sessionData, isLoading, router, socket])
useEffect(() => {
if (!socket) return
socket.on('arcade-exited', () => {
console.log('🚪 Arcade exited from another device')
router.push('/games')
})
return () => {
socket.off('arcade-exited')
}
}, [socket, router])
return { session: sessionData, isLoading }
}
Apply to Pages
Use in:
/arcade/page.tsx/arcade/matching/page.tsx/arcade/memory-quiz/page.tsx/arcade/complement-race/page.tsx
export default function MatchingPage() {
useArcadeGuard() // Auto-redirects if different game active
return <MatchingGame />
}
Phase 7: Exit Button in Mini Nav
File: apps/web/src/components/PageWithNav.tsx
Update to add exit button:
import { useArcadeSocket } from '@/hooks/useArcadeSocket'
import { useRouter } from 'next/navigation'
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function PageWithNav({ emphasizeGameContext, ...props }) {
const { socket } = useArcadeSocket()
const router = useRouter()
const queryClient = useQueryClient()
const { mutate: exitArcade } = useMutation({
mutationFn: async () => {
await fetch('/api/arcade-session', { method: 'DELETE' })
},
onSuccess: () => {
socket?.emit('exit-arcade', { userId: /* get from context */ })
queryClient.invalidateQueries(['arcadeSession'])
router.push('/games')
},
})
return (
<div>
{emphasizeGameContext && (
<button
onClick={() => exitArcade()}
className={css({
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1000,
bg: 'red.500',
color: 'white',
px: '4',
py: '2',
rounded: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
_hover: { bg: 'red.600' },
})}
>
🚪 Exit Game
</button>
)}
{/* Rest of nav */}
</div>
)
}
Phase 8: Route Migration
Move Games to /arcade/*
# Create new directories
mkdir -p apps/web/src/app/arcade/matching
mkdir -p apps/web/src/app/arcade/memory-quiz
mkdir -p apps/web/src/app/arcade/complement-race
# Move game files
mv apps/web/src/app/games/matching/* apps/web/src/app/arcade/matching/
mv apps/web/src/app/games/memory-quiz/* apps/web/src/app/arcade/memory-quiz/
mv apps/web/src/app/games/complement-race/* apps/web/src/app/arcade/complement-race/
# Keep /games as landing page
# apps/web/src/app/games/page.tsx remains
Update Game URLs
File: apps/web/src/components/GameSelector.tsx
export const GAMES_CONFIG = {
'memory-lightning': {
// ...
url: '/arcade/memory-quiz', // Changed from /games/memory-quiz
},
'battle-arena': {
// ...
url: '/arcade/matching', // Changed from /games/matching
},
'complement-race': {
// ...
url: '/arcade/complement-race', // Changed from /games/complement-race
},
// ...
}
Phase 9: TTL & Session Cleanup
Cleanup Utility
File: apps/web/src/lib/arcade-session-cleanup.ts
import { db, schema } from '@/db'
import { lt } from 'drizzle-orm'
export async function cleanupExpiredSessions() {
const now = new Date()
const deleted = await db
.delete(schema.arcadeSessions)
.where(lt(schema.arcadeSessions.expiresAt, now))
.returning()
console.log(`🧹 Cleaned up ${deleted.length} expired arcade sessions`)
return deleted
}
Cron API Route
File: apps/web/src/app/api/cron/cleanup-sessions/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cleanupExpiredSessions } from '@/lib/arcade-session-cleanup'
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const cleaned = await cleanupExpiredSessions()
return NextResponse.json({
success: true,
cleanedCount: cleaned.length
})
}
Vercel Cron Config
File: vercel.json
{
"crons": [{
"path": "/api/cron/cleanup-sessions",
"schedule": "0 */6 * * *"
}]
}
Phase 10: User Feedback (Toast/UI)
Toast for Rejected Moves
File: apps/web/src/components/MoveRejectedToast.tsx
import { toast } from 'sonner'
export function showMoveRejectedToast(error: string) {
const userFriendlyMessage = getUserFriendlyError(error)
toast.error('Move Invalid', {
description: userFriendlyMessage,
duration: 3000,
action: {
label: 'Got it',
onClick: () => {},
},
})
}
function getUserFriendlyError(error: string): string {
const errorMap: Record<string, string> = {
'Card already matched': 'That card has already been matched!',
'Too many cards flipped': 'Please wait for cards to flip back',
'Not your turn': 'Wait for your turn!',
'Cards do not match': 'Those cards don\'t match - server verification failed',
'Card not found': 'Invalid card selection',
'State out of sync': 'Game state was updated by another device',
}
return errorMap[error] || 'Invalid move - please try again'
}
Pending State UI
In game components:
const { state, applyMove, isPending } = useOptimisticGameState(
initialState,
'battle-arena',
userId
)
return (
<div className={css({
opacity: isPending ? 0.7 : 1,
pointerEvents: isPending ? 'none' : 'auto',
transition: 'opacity 0.2s'
})}>
{isPending && (
<div className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bg: 'white',
p: 4,
rounded: 'lg',
shadow: 'lg',
})}>
⏳ Verifying move...
</div>
)}
{/* Game UI */}
</div>
)
Implementation Checklist
Database & Schema
- Create
arcade_sessionstable schema - Generate and run migration
- Add TTL cleanup utility
- Create cron job for cleanup
WebSocket Infrastructure
- Install socket.io dependencies
- Create custom Next.js server
- Implement socket server with rooms
- Add move validation in socket handlers
Game Validation
- Create validation types/interfaces
- Implement MatchingGameValidator
- Create validator registry
- Add validators for other games (memory-quiz, complement-race)
Client Integration
- Create useArcadeSocket hook
- Create useOptimisticGameState hook
- Implement optimistic reducer with rollback
- Add socket event listeners
API Routes
- GET /api/arcade-session
- POST /api/arcade-session
- DELETE /api/arcade-session
Navigation & UX
- Create useArcadeGuard hook
- Apply guard to all arcade pages
- Add exit button to mini nav
- Create move rejection toast component
- Add pending state UI
Route Migration
- Move /games/matching → /arcade/matching
- Move /games/memory-quiz → /arcade/memory-quiz
- Move /games/complement-race → /arcade/complement-race
- Update GAMES_CONFIG URLs
- Keep /games as landing page
Testing
- Test single device reload (should resume)
- Test multi-tab sync
- Test cross-device sync
- Test exit button (all devices)
- Test move rejection/rollback
- Test TTL cleanup
- Test version conflicts
Success Criteria
- ✅ User starts game in tab A, opens tab B → sees same game
- ✅ User reloads page → returns to exact game state
- ✅ User starts game on desktop, opens mobile → synced state
- ✅ Invalid move → rejected with user-friendly message, UI rolls back
- ✅ Exit button → all tabs/devices return to /games
- ✅ Sessions older than 24h → automatically cleaned up
- ✅ All game routes under /arcade/*
Notes
- Version locking: Prevents concurrent edits from different devices causing conflicts
- Optimistic updates: UI feels instant, server validates asynchronously
- Shared validators: Same logic client & server = consistent behavior
- TTL: Automatic cleanup prevents database bloat
- WebSocket rooms: Efficient broadcast to user's devices only
Critical Design Decision: Guest ID vs User ID
The Problem (Discovered During Implementation)
The system has TWO different user identifiers:
-
users.guestId(UUID from cookie) - What the frontend knows about- Stored in HttpOnly cookie
- Returned by
getViewerId() - Used throughout the API layer
- Example:
149e3e7e-4006-4a17-9f9f-28b0ec188c28
-
users.id(Auto-generated CUID) - Database primary key- Auto-generated via
createId()from@paralleldrive/cuid2 - Used for foreign key relationships
- Example:
m2rb9gjhhqp2fky171quf1lj
- Auto-generated via
Database Schema
// apps/web/src/db/schema/users.ts
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => createId()),
guestId: text('guest_id').notNull().unique(),
// ...
})
// apps/web/src/db/schema/arcade-sessions.ts
export const arcadeSessions = sqliteTable('arcade_sessions', {
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }), // FK to users.id, NOT guestId
// ...
})
Why Two IDs?
-
Guest accounts can upgrade to permanent accounts
guestIdremains stable (from cookie)- But user might get a real email/name later
- Internal
idstays the same through the upgrade
-
Database normalization
- Primary keys should be immutable
guestIdcomes from external source (cookie)idis internally controlled
The Wrong Approach (Initial Bug)
// ❌ WRONG - Tries to use guestId as primary key
await db.insert(schema.users).values({
id: guestId, // UUID as PK
guestId: guestId, // Same UUID
})
await db.insert(schema.arcadeSessions).values({
userId: guestId, // FK constraint fails!
})
Why it fails:
- User already exists with
id=m2rb9gjhhqp2fky171quf1ljandguestId=149e3e7e-... - Code tries to insert with
id=149e3e7e-... onConflictDoNothing()sees the guestId UNIQUE conflict and skips- Then tries to create session with
userId=149e3e7e-... - But FK expects
users.id, which ism2rb9gjhhqp2fky171quf1lj, not the guestId - FOREIGN KEY constraint failed
The Correct Approach (Translation Layer)
// ✅ CORRECT - Translate guestId → users.id at database boundary
async function getUserIdFromGuestId(guestId: string): Promise<string | undefined> {
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, guestId),
columns: { id: true }
})
return user?.id
}
export async function createArcadeSession(options: CreateSessionOptions) {
// Find or create user by guestId
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, options.userId) // userId is actually guestId here
})
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({
guestId: options.userId, // Let id auto-generate
})
.returning()
user = newUser
}
// Use the actual database ID for FK
await db.insert(schema.arcadeSessions).values({
userId: user.id, // ✅ Actual database ID, not guestId
// ...
})
}
API Layer Convention
The API layer uses guestId as the user identifier:
// apps/web/src/lib/viewer.ts
export async function getViewerId(): Promise<string> {
const viewer = await getViewer()
switch (viewer.kind) {
case 'user':
return viewer.session!.user!.id
case 'guest':
return viewer.guestId // ← Returns guestId for guests
}
}
All WebSocket events, API routes, and hooks use this viewerId (which is guestId for guests).
Implementation Rules
- Frontend/API Layer: Always use
guestId(fromgetViewerId()) - Database Boundary: Translate
guestId→users.idbefore any FK operations - Never use
guestIdin foreign key constraints - always useusers.id - Helper function pattern: Create
getUserIdFromGuestId()helpers in database utilities
Files Updated to Fix This
apps/web/src/lib/arcade/session-manager.ts- Added translation layergetUserIdFromGuestId()helper functioncreateArcadeSession()now looks up actual user IDgetArcadeSession()translates guestId before queryapplyGameMove()uses translated ID (via getArcadeSession)deleteArcadeSession()uses translated ID (via getArcadeSession)