soroban-abacus-flashcards/apps/web/docs/arcade-rooms-technical-plan.md

22 KiB

🎮 Arcade Room System - Complete Technical Plan

Date: 2025-01-06 Status: Ready for Implementation

Executive Summary

Transform the current singleton arcade session into a multi-room system where users can create, manage, and share public game rooms. Rooms are URL-addressable, support guest users, have configurable TTL, and give creators full moderation control.


1. Core Requirements

Room Features

  • Public by default - All rooms visible in public lobby
  • No capacity limits - Unlimited players per room
  • Configurable TTL - Rooms expire based on inactivity (similar to existing session TTL)
  • URL-addressable - Direct links to join rooms (/arcade/rooms/{roomId})
  • Guest access - Unauthenticated users can join with temp guest IDs
  • Anyone can create - No authentication required to create rooms
  • Creator moderation - Only room creator can kick, lock, or delete room

2. Database Schema

New Tables

// arcade_rooms table
interface ArcadeRoom {
  id: string; // UUID - primary key
  code: string; // 6-char join code (e.g., "ABC123") - unique
  name: string; // User-defined room name (max 50 chars)

  // Creator info
  createdBy: string; // User/guest ID of creator
  creatorName: string; // Display name at creation time
  createdAt: Date;

  // Lifecycle
  lastActivity: Date; // Updated on any room activity
  ttlMinutes: number; // Time to live in minutes (default: 60)
  isLocked: boolean; // Creator can lock room (no new joins)

  // Game configuration
  gameName: string; // 'matching', 'complement-race', etc.
  gameConfig: JSON; // Game-specific settings (difficulty, etc.)

  // Current state
  status: "lobby" | "playing" | "finished";
  currentSessionId: string | null; // FK to arcade_sessions (when game active)

  // Metadata
  totalGamesPlayed: number; // Track room usage
}

// room_members table
interface RoomMember {
  id: string; // UUID - primary key
  roomId: string; // FK to arcade_rooms - indexed
  userId: string; // User/guest ID - indexed
  displayName: string; // Name shown in room
  isCreator: boolean; // True for room creator
  joinedAt: Date;
  lastSeen: Date; // Updated on any activity
  isOnline: boolean; // Currently connected via socket
}

// Modify existing: arcade_sessions
interface ArcadeSession {
  id: string;
  userId: string;
  // ... existing fields
  roomId: string | null; // FK to arcade_rooms (null for solo play)
  // When roomId is set, session is shared across room members
}

Indexes

CREATE INDEX idx_rooms_code ON arcade_rooms(code);
CREATE INDEX idx_rooms_status ON arcade_rooms(status);
CREATE INDEX idx_rooms_last_activity ON arcade_rooms(lastActivity);
CREATE INDEX idx_room_members_room_id ON room_members(roomId);
CREATE INDEX idx_room_members_user_id ON room_members(userId);
CREATE INDEX idx_room_members_online ON room_members(isOnline) WHERE isOnline = true;

3. API Endpoints

Room CRUD (/api/arcade/rooms)

// Create room
POST /api/arcade/rooms
Body: {
  name: string              // Room name
  gameName: string          // Which game
  gameConfig?: object       // Game settings
  ttlMinutes?: number       // Default: 60
  creatorName: string       // Display name
}
Response: {
  room: ArcadeRoom
  joinUrl: string           // Full URL to share
}

// Get room details
GET /api/arcade/rooms/:roomId
Response: {
  room: ArcadeRoom
  members: RoomMember[]
  canModerate: boolean      // True if requester is creator
}

// Update room (creator only)
PATCH /api/arcade/rooms/:roomId
Body: {
  name?: string
  isLocked?: boolean
  ttlMinutes?: number
}
Response: { room: ArcadeRoom }

// Delete room (creator only)
DELETE /api/arcade/rooms/:roomId
Response: { success: boolean }

// Join room by code
GET /api/arcade/rooms/join/:code
Response: {
  roomId: string
  redirectUrl: string
}

Room Discovery

// Public room lobby (list all active rooms)
GET /api/arcade/rooms/lobby
Query: {
  gameName?: string         // Filter by game
  status?: string           // Filter by status
  limit?: number            // Default: 50
  offset?: number
}
Response: {
  rooms: Array<{
    id: string
    name: string
    code: string
    gameName: string
    status: string
    memberCount: number
    createdAt: Date
    creatorName: string
  }>
  total: number
}

// Get user's rooms (all rooms user has joined)
GET /api/arcade/rooms/my-rooms
Query: { userId: string }
Response: {
  rooms: Array<RoomWithMemberInfo>
}

Room Membership

// Join room
POST /api/arcade/rooms/:roomId/join
Body: {
  userId: string            // User or guest ID
  displayName: string
}
Response: {
  member: RoomMember
  room: ArcadeRoom
}

// Leave room
POST /api/arcade/rooms/:roomId/leave
Body: { userId: string }
Response: { success: boolean }

// Get members
GET /api/arcade/rooms/:roomId/members
Response: {
  members: RoomMember[]
  onlineCount: number
}

// Kick member (creator only)
DELETE /api/arcade/rooms/:roomId/members/:userId
Response: { success: boolean }

Room Game Session

// Start game in room
POST /api/arcade/rooms/:roomId/start-game
Body: {
  initiatedBy: string       // Must be room member
  activePlayers: string[]   // Subset of room members
}
Response: {
  sessionId: string
  gameState: any
}

// End game (return to lobby)
POST /api/arcade/rooms/:roomId/end-game
Body: { initiatedBy: string }
Response: { success: boolean }

4. WebSocket Protocol

Socket.IO Room Namespacing

// Join room's socket.io room
socket.emit('join-room', {
  roomId: string
  userId: string
})

// Leave room
socket.emit('leave-room', {
  roomId: string
  userId: string
})

// Update member presence
socket.emit('update-presence', {
  roomId: string
  userId: string
  isOnline: boolean
})

Server → Client Events (room-scoped broadcasts)

// Room state changes
socket.on('room-updated', {
  room: ArcadeRoom
})

// Member events
socket.on('member-joined', {
  member: RoomMember
  memberCount: number
})

socket.on('member-left', {
  userId: string
  memberCount: number
})

socket.on('member-kicked', {
  kickedUserId: string
  reason: string
})

socket.on('members-updated', {
  members: RoomMember[]
})

// Game session events
socket.on('game-starting', {
  sessionId: string
  activePlayers: string[]
})

socket.on('game-ended', {
  results: any
})

// Room lifecycle
socket.on('room-locked', {
  isLocked: boolean
})

socket.on('room-deleted', {
  reason: string
})

// Existing game moves (now room-scoped)
socket.on('game-move', { roomId, userId, move })
socket.on('move-accepted', { roomId, gameState, version })
socket.on('move-rejected', { roomId, error })

5. URL Structure & Routing

New Routes

/arcade/rooms                           // Public room lobby (list all rooms)
/arcade/rooms/create                    // Create room modal/page
/arcade/rooms/:roomId                   // Room lobby (pre-game)
/arcade/rooms/:roomId/matching          // Game with room context
/arcade/rooms/:roomId/complement-race   // Another game
/arcade/join/:code                      // Short link: redirects to room

// Existing routes (backward compatible)
/arcade                                 // My rooms + quick play
/arcade/matching                        // Solo play (no room)

Navigation Flow

User Journey A: Create Room
1. /arcade → Click "Create Room"
2. /arcade/rooms/create → Fill form, submit
3. /arcade/rooms/{roomId} → Room lobby, share link
4. Click "Start Game" → /arcade/rooms/{roomId}/matching

User Journey B: Join via Link
1. Receive link: example.com/arcade/rooms/{roomId}
2. Opens lobby, automatically joins
3. Wait for game start or click ready

User Journey C: Join via Code
1. /arcade → Click "Join Room", enter ABC123
2. Resolves code → /arcade/rooms/{roomId}
3. Join and wait

User Journey D: Browse Lobby
1. /arcade/rooms → See public room list
2. Click room → /arcade/rooms/{roomId}
3. Join and play

6. UI Components Architecture

Component Hierarchy

/src/components/arcade/rooms/
├── RoomLobbyBrowser.tsx           // Public room list (/arcade/rooms)
│   ├── RoomCard.tsx               // Individual room preview
│   └── RoomFilters.tsx            // Filter by game, status
│
├── RoomLobby.tsx                  // Pre-game lobby (/arcade/rooms/:roomId)
│   ├── RoomHeader.tsx             // Room name, code, share button
│   ├── RoomMemberList.tsx         // Online members
│   ├── RoomSettings.tsx           // Creator-only settings
│   └── RoomActions.tsx            // Start game, leave, etc.
│
├── CreateRoomDialog.tsx           // Room creation modal
│   ├── GameSelector.tsx           // Choose game type
│   ├── RoomNameInput.tsx          // Name the room
│   └── AdvancedSettings.tsx       // TTL, etc.
│
├── JoinRoomDialog.tsx             // Join by code modal
├── RoomContextIndicator.tsx       // Shows room info during game
│   └── RoomMemberAvatars.tsx      // Small member list
│
└── RoomModeration.tsx             // Creator controls (kick, lock, delete)

Navigation Updates

// Update GameContextNav.tsx
interface GameContextNavProps {
  // ... existing props
  roomContext?: {
    roomId: string;
    roomName: string;
    roomCode: string;
    memberCount: number;
    isCreator: boolean;
  };
}

// Shows in nav during room gameplay:
// [🏠 Friday Night (ABC123) • 5 players ▼]

7. State Management

New Hooks

// useArcadeRoom.ts - Room state and membership
export function useArcadeRoom(roomId: string) {
  return {
    room: ArcadeRoom | null
    members: RoomMember[]
    isCreator: boolean
    isOnline: boolean
    joinRoom: (displayName: string) => Promise<void>
    leaveRoom: () => Promise<void>
    updateRoom: (updates: Partial<ArcadeRoom>) => Promise<void>
    deleteRoom: () => Promise<void>
    kickMember: (userId: string) => Promise<void>
    startGame: (activePlayers: string[]) => Promise<void>
    endGame: () => Promise<void>
  }
}

// useRoomMembers.ts - Real-time member presence
export function useRoomMembers(roomId: string) {
  return {
    members: RoomMember[]
    onlineMembers: RoomMember[]
    onlineCount: number
    updatePresence: (isOnline: boolean) => void
  }
}

// useRoomLobby.ts - Public room discovery
export function useRoomLobby(filters?: RoomFilters) {
  return {
    rooms: RoomPreview[]
    loading: boolean
    refresh: () => void
    loadMore: () => void
  }
}

// Update useArcadeSession.ts
export function useArcadeSession<TState>(options: {
  userId: string
  roomId?: string  // NEW: optional room context
  // ... existing options
}) {
  // If roomId provided, session is room-scoped
  // All moves broadcast to room members
}

8. Server Implementation

New Files

/src/lib/arcade/
├── room-manager.ts              # Core room operations
│   ├── createRoom()
│   ├── getRoomById()
│   ├── updateRoom()
│   ├── deleteRoom()
│   ├── getRoomByCode()
│   └── getPublicRooms()
│
├── room-membership.ts           # Member management
│   ├── joinRoom()
│   ├── leaveRoom()
│   ├── kickMember()
│   ├── getRoomMembers()
│   └── updateMemberPresence()
│
├── room-validation.ts           # Access control
│   ├── canModerateRoom()
│   ├── canJoinRoom()
│   ├── canStartGame()
│   └── validateRoomName()
│
├── room-ttl.ts                  # TTL management (reuse existing pattern)
│   ├── scheduleRoomCleanup()
│   ├── updateRoomActivity()
│   └── cleanupExpiredRooms()
│
└── session-manager.ts           # Update for room support
    └── createArcadeSession() - accept roomId param

Socket Server Updates

// socket-server.ts modifications

io.on("connection", (socket) => {
  // Join room (socket.io namespace)
  socket.on("join-room", async ({ roomId, userId }) => {
    // Validate membership
    const member = await getRoomMember(roomId, userId);
    if (!member) {
      socket.emit("room-error", { error: "Not a room member" });
      return;
    }

    // Join socket.io room
    socket.join(`room:${roomId}`);

    // Update presence
    await updateMemberPresence(roomId, userId, true);

    // Broadcast to room
    io.to(`room:${roomId}`).emit("member-joined", { member });

    // Send current state
    const room = await getRoomById(roomId);
    const members = await getRoomMembers(roomId);
    socket.emit("room-state", { room, members });
  });

  // Leave room
  socket.on("leave-room", async ({ roomId, userId }) => {
    socket.leave(`room:${roomId}`);
    await updateMemberPresence(roomId, userId, false);
    io.to(`room:${roomId}`).emit("member-left", { userId });
  });

  // Game moves (room-scoped)
  socket.on("game-move", async ({ roomId, userId, move }) => {
    // Validate room membership
    const member = await getRoomMember(roomId, userId);
    if (!member) return;

    // Apply move to room's session
    const result = await applyGameMove(userId, move, roomId);

    if (result.success) {
      // Broadcast to all room members
      io.to(`room:${roomId}`).emit("move-accepted", {
        gameState: result.session.gameState,
        version: result.session.version,
        move,
      });
    }
  });

  // Disconnect handling
  socket.on("disconnect", () => {
    // Update presence for all rooms user was in
    updateAllUserPresence(userId, false);
  });
});

9. Guest User System

Guest ID Generation

// /src/lib/guest-id.ts

export function generateGuestId(): string {
  // Format: guest_{timestamp}_{random}
  // Example: guest_1704556800000_a3f2e1
  const timestamp = Date.now();
  const random = Math.random().toString(36).substring(2, 8);
  return `guest_${timestamp}_${random}`;
}

export function isGuestId(userId: string): boolean {
  return userId.startsWith("guest_");
}

export function getGuestDisplayName(guestId: string): string {
  // Generate friendly name like "Guest 1234"
  const hash = guestId.split("_")[2];
  const num = parseInt(hash, 36) % 10000;
  return `Guest ${num}`;
}

Guest Session Storage

// Store guest ID in localStorage
const GUEST_ID_KEY = "soroban_guest_id";

export function getOrCreateGuestId(): string {
  let guestId = localStorage.getItem(GUEST_ID_KEY);
  if (!guestId) {
    guestId = generateGuestId();
    localStorage.setItem(GUEST_ID_KEY, guestId);
  }
  return guestId;
}

10. TTL Implementation

Reuse Existing Session TTL Pattern

// room-ttl.ts

const DEFAULT_ROOM_TTL_MINUTES = 60;

// Cleanup job (run every 5 minutes)
setInterval(
  async () => {
    await cleanupExpiredRooms();
  },
  5 * 60 * 1000,
);

async function cleanupExpiredRooms() {
  const now = new Date();

  // Find expired rooms
  const expiredRooms = await db.query(`
    SELECT id FROM arcade_rooms
    WHERE status != 'playing'
    AND lastActivity < NOW() - INTERVAL '1 minute' * ttlMinutes
  `);

  for (const room of expiredRooms) {
    // Notify members
    io.to(`room:${room.id}`).emit("room-deleted", {
      reason: "Room expired due to inactivity",
    });

    // Delete room and members
    await deleteRoom(room.id);
  }
}

// Update activity on any room action
export async function touchRoom(roomId: string) {
  await db.query(
    `
    UPDATE arcade_rooms
    SET lastActivity = NOW()
    WHERE id = $1
  `,
    [roomId],
  );
}

11. Room Code Generation

// /src/lib/arcade/room-code.ts

const CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No ambiguous chars
const CODE_LENGTH = 6;

export async function generateUniqueRoomCode(): Promise<string> {
  let attempts = 0;
  const maxAttempts = 10;

  while (attempts < maxAttempts) {
    const code = generateCode();
    const existing = await getRoomByCode(code);
    if (!existing) return code;
    attempts++;
  }

  throw new Error("Failed to generate unique room code");
}

function generateCode(): string {
  let code = "";
  for (let i = 0; i < CODE_LENGTH; i++) {
    const idx = Math.floor(Math.random() * CODE_CHARS.length);
    code += CODE_CHARS[idx];
  }
  return code;
}

12. Migration Plan

Phase 1: Database & API Foundation (Day 1-2)

  1. Create database tables and indexes
  2. Implement room-manager.ts and room-membership.ts
  3. Build API endpoints
  4. Add room-code generation
  5. Implement TTL cleanup
  6. Write unit tests

Phase 2: Socket.IO Integration (Day 2-3)

  1. Update socket-server.ts for room namespacing
  2. Implement room-scoped broadcasts
  3. Add presence tracking
  4. Update session-manager.ts for roomId support
  5. Test multi-user room sessions

Phase 3: Guest User System (Day 3)

  1. Implement guest ID generation
  2. Add guest user hooks
  3. Update auth flow for optional guest access
  4. Test guest join/leave flow

Phase 4: UI Components (Day 4-5)

  1. Build CreateRoomDialog
  2. Build RoomLobby component
  3. Build RoomLobbyBrowser (public lobby)
  4. Add RoomContextIndicator to nav
  5. Wire up all hooks

Phase 5: Routes & Navigation (Day 5-6)

  1. Create /arcade/rooms routes
  2. Update arcade home for room access
  3. Add room selector to nav
  4. Implement join-by-code flow
  5. Add share room functionality

Phase 6: Testing & Polish (Day 6-7)

  1. E2E tests for room creation/join
  2. Multi-user gameplay tests
  3. TTL and cleanup tests
  4. Guest user flow tests
  5. Performance testing
  6. UI polish and error states

13. Backward Compatibility

Solo Play Preserved

  • Existing /arcade/matching routes work unchanged
  • roomId = null for solo sessions
  • No breaking changes to useArcadeSession
  • All current functionality remains intact

Migration Strategy

  • Add roomId column to arcade_sessions (nullable)
  • Existing sessions have roomId = null
  • New room-based sessions have roomId populated
  • Session logic checks: if (roomId) { /* room mode */ }

14. Security Considerations

Room Access

  • Validate room membership before allowing game moves
  • Check isCreator flag for moderation actions
  • Prevent joining locked rooms
  • Rate limit room creation per IP/user
  • Sanitize room names (max length, no XSS)

Guest Users

  • Guest IDs stored client-side only (localStorage)
  • No sensitive data in guest profiles
  • Guest names sanitized
  • Rate limit guest actions
  • Allow authenticated users to "claim" guest activity

Room Moderation

  • Only creator can kick/lock/delete
  • Kicked users cannot rejoin unless creator unlocks
  • Room deletion clears all associated data
  • Audit log for moderation actions

15. Testing Strategy

Unit Tests

  • Room CRUD operations
  • Member join/leave logic
  • Code generation uniqueness
  • TTL cleanup
  • Guest ID generation
  • Access control validation

Integration Tests

  • Full room creation → join → play → leave flow
  • Multi-user concurrent gameplay
  • Socket.IO room broadcasts
  • Session synchronization across tabs
  • TTL expiration and cleanup

E2E Tests (Playwright)

  • Create room → share link → join as guest
  • Browse lobby → join room → play game
  • Creator kicks member
  • Room locks and unlock
  • Room TTL expiration

Load Tests

  • 100+ concurrent rooms
  • 10+ players per room
  • Rapid join/leave cycles
  • Socket.IO message throughput

16. Performance Optimizations

Database

  • Index on room_members.roomId for fast member queries
  • Index on arcade_rooms.code for quick code lookups
  • Index on room_members.isOnline for presence queries
  • Partition arcade_rooms by createdAt for TTL cleanup

Caching

  • Cache active room list (1-minute TTL)
  • Cache room member counts
  • Redis pub/sub for cross-server socket broadcasts (future)

Socket.IO

  • Use socket.io rooms for efficient broadcasting
  • Batch presence updates (debounce member online status)
  • Compress socket messages for large game states

17. Future Enhancements (Post-MVP)

  1. Room Templates - Save room configurations
  2. Private Rooms - Invite-only with passwords
  3. Room Chat - Text chat in lobby
  4. Spectator Mode - Watch games without playing
  5. Room History - Past games and stats
  6. Tournament Brackets - Multi-round competitions
  7. Room Search - Search by name/tag
  8. Room Tags - Categorize rooms
  9. Friend Integration - Invite friends directly
  10. Room Analytics - Popular times, average players, etc.

18. Open Questions / Decisions Needed

  1. Room Name Validation - Max length? Profanity filter?
  2. Default TTL - 60 minutes good default?
  3. Code Reuse - Can codes be reused after room expires?
  4. Member Limit - Even though unlimited, warn at certain threshold?
  5. Lobby Pagination - How many rooms to show initially?

19. Success Metrics

  • Users can create and join rooms
  • Guest users can participate without auth
  • Multi-user gameplay synchronized across all room members
  • Room creators can moderate effectively
  • Rooms expire correctly based on TTL
  • Public lobby shows active rooms
  • No regressions in solo play mode
  • All tests passing (unit, integration, e2e)

20. Dependencies

Existing Systems to Leverage

  • Current arcade session management
  • Existing WebSocket infrastructure (socket-server.ts)
  • Database setup (PostgreSQL)
  • Guest player system (from GameModeContext)

New Dependencies (if needed)

  • None! All can be built with existing stack

Implementation Checklist

  • Create database migration
  • Implement room-manager.ts
  • Implement room-membership.ts
  • Build API endpoints
  • Add room code generation
  • Update socket-server.ts
  • Implement guest ID system
  • Build CreateRoomDialog
  • Build RoomLobby component
  • Build RoomLobbyBrowser
  • Add room routes
  • Update navigation
  • Write unit tests
  • Write integration tests
  • Write e2e tests
  • Documentation
  • Deploy

Ready to implement! 🚀