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)