13 KiB
Pluggable Room/WebRTC Architecture
This document describes the layered architecture that separates reusable Room/WebRTC infrastructure from application-specific logic.
Overview
The system is designed with three distinct layers:
- Infrastructure Layer - Reusable Room/Session/WebRTC management
- Metadata Layer - Application-specific data attached to infrastructure
- Adapter Layer - Backward compatibility and migration support
This separation allows MediaControl and Room management to be reused across different applications without modification.
Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
│ (Game Logic - Settlers of Catan specific) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Game Rules, Turns, Resources, Trading, etc. │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────────────┘
│ uses metadata
┌────────────────────▼────────────────────────────────────────┐
│ Metadata Layer │
│ (Application-specific data structures) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Session.metadata { color, player, resources } │ │
│ │ Room.metadata { game state, board, cards, etc. } │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────────────┘
│ attached to
┌────────────────────▼────────────────────────────────────────┐
│ Infrastructure Layer │
│ (Reusable across applications) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ BaseSession: id, name, ws, live, has_media │ │
│ │ BaseRoom: id, sessions, state │ │
│ │ MediaControl: WebRTC signaling, peer management │ │
│ │ Room Helpers: getParticipants, session management │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Type Hierarchy
Infrastructure Types (Reusable)
Located in: server/routes/room/types.ts
// Core session - no application logic
interface BaseSession {
id: string;
name: string | null;
ws?: WebSocket;
live: boolean;
has_media: boolean;
// ... infrastructure fields only
}
// Core room - no application logic
interface BaseRoom {
id: string;
sessions: Record<string, BaseSession>;
state: string;
// ... infrastructure fields only
}
// Generic with metadata support
interface Session<TMetadata = any> extends BaseSession {
metadata?: TMetadata; // Application data goes here
}
interface Room<TMetadata = any> extends BaseRoom {
metadata?: TMetadata; // Application data goes here
}
Application-Specific Types
Located in: server/routes/games/gameMetadata.ts
// Game-specific session data
interface GameSessionMetadata {
color?: string; // Player color selection
player?: Player; // Reference to game player
resources?: number; // Temporary resource count
}
// Game-specific room data
interface GameRoomMetadata {
players: Record<string, Player>; // Game players
turn: Turn; // Current turn
placements: Placements; // Board state
chat: any[]; // Chat messages
// ... all game-specific fields
}
// Convenience types
type GameSession = Session<GameSessionMetadata>;
type GameRoom = Room<GameRoomMetadata>;
Key Principles
1. Clear Separation of Concerns
Infrastructure Layer Responsibilities:
- WebSocket connection management
- Session lifecycle (connect, disconnect, timeout)
- WebRTC signaling (join, peer, ICE, SDP)
- Participant listing
- Room state management
Metadata Layer Responsibilities:
- Application-specific data structures
- Business logic
- Game rules and validation
- Application state
2. Metadata Pattern
All application-specific data is stored in metadata
fields:
// ❌ Old approach - mixed concerns
interface Session {
id: string; // Infrastructure
name: string; // Infrastructure
color: string; // APPLICATION SPECIFIC! ❌
player: Player; // APPLICATION SPECIFIC! ❌
}
// ✅ New approach - separated concerns
interface Session<TMetadata> {
id: string; // Infrastructure
name: string; // Infrastructure
metadata?: TMetadata; // Application-specific data
}
// Usage
const session: Session<GameSessionMetadata> = {
id: "abc123",
name: "Alice",
metadata: {
color: "red", // Game-specific
player: {...} // Game-specific
}
};
3. Extensibility
Any application can use the infrastructure by defining their own metadata types:
// Example: Chat application
interface ChatSessionMetadata {
displayColor: string;
status: "available" | "away" | "busy";
lastTyping?: number;
}
type ChatSession = Session<ChatSessionMetadata>;
// Example: Whiteboard application
interface WhiteboardSessionMetadata {
tool: "pen" | "eraser" | "shape";
color: string;
brushSize: number;
}
type WhiteboardSession = Session<WhiteboardSessionMetadata>;
Reusable Components
MediaControl (Client)
Located in: client/src/MediaControl.tsx
Reusable: ✅ No application-specific logic
// MediaControl only needs infrastructure data
<MediaControl
peer={peer} // Has: session_id, peer_name, srcObject
isSelf={isLocal}
sendJsonMessage={send}
/>
// MediaAgent only needs infrastructure
<MediaAgent
session={session} // Has: id, name, has_media
socketUrl={url}
peers={peers}
setPeers={setPeers}
/>
Room Helpers (Server)
Located in: server/routes/room/helpers.ts
Reusable: ✅ Generic functions work with any metadata
import { getParticipants } from './room/helpers';
// Works with any Room<T>
const participants = getParticipants(room.sessions);
// Returns base participant data (no app-specific fields)
// [{ session_id, name, live, has_media, ... }]
Extending Participants
Applications can extend the base participant list:
// Get base participants (reusable)
const baseParticipants = getParticipants(room.sessions);
// Add application-specific data
const gameParticipants = baseParticipants.map(p => ({
...p,
color: room.sessions[p.session_id].metadata?.color || null
}));
Migration Strategy
Phase 1: Adapter Layer (Current)
Use the adapter layer to maintain backward compatibility:
import { wrapGame, wrapSession } from './games/gameAdapter';
// Wrap existing game objects
const game = wrapGame(loadedGame);
// Code still works with old syntax
session.color = "red"; // Proxied to session.metadata.color
game.turn = {...}; // Proxied to game.metadata.turn
Phase 2: Gradual Migration
Migrate functions one at a time:
// Old code
function doSomething(game: any) {
const color = session.color; // Direct access
}
// New code
function doSomething(game: GameRoom) {
const color = session.metadata?.color; // Metadata access
}
Phase 3: Pure Architecture
Eventually remove adapters and use pure metadata architecture:
// All application data in metadata
const session: GameSession = {
id: "123",
name: "Alice",
has_media: true,
live: true,
// ... base fields
metadata: {
color: "red",
player: {...},
resources: 5
}
};
Usage Examples
Example 1: Creating a New Room Type
// Define your metadata
interface MyAppSessionMetadata {
customField: string;
}
interface MyAppRoomMetadata {
appState: any;
}
// Use the base types
type MySession = Session<MyAppSessionMetadata>;
type MyRoom = Room<MyAppRoomMetadata>;
// Reuse infrastructure
import { getParticipants } from './room/helpers';
function listUsers(room: MyRoom) {
return getParticipants(room.sessions);
}
Example 2: Extending Participant Data
// Start with base participants
const participants = getParticipants(room.sessions);
// Add app-specific fields
const extendedParticipants = participants.map(p => {
const session = room.sessions[p.session_id];
return {
...p,
// Add your custom fields
customField: session.metadata?.customField,
};
});
Example 3: MediaControl Integration
// MediaControl is fully reusable
import { MediaAgent, MediaControl } from './MediaControl';
function MyApp() {
const [peers, setPeers] = useState<Record<string, Peer>>({});
return (
<>
<MediaAgent
session={session} // Base session data
socketUrl={socketUrl}
peers={peers}
setPeers={setPeers}
/>
{participants.map(p => (
<MediaControl
peer={peers[p.session_id]}
isSelf={p.session_id === session.id}
/>
))}
</>
);
}
Benefits
1. Reusability
- MediaControl can be used in any WebRTC application
- Room management works for any multi-user application
- WebSocket handling is application-agnostic
2. Maintainability
- Clear boundaries between infrastructure and application
- Easy to test each layer independently
- Simpler upgrade paths
3. Type Safety
- Generic types ensure type safety across layers
- TypeScript enforces proper metadata usage
- IntelliSense works correctly
4. Flexibility
- Easy to add new applications using same infrastructure
- Can run multiple different applications on same server
- Minimal code changes to adapt to new use cases
File Organization
server/
├── routes/
│ ├── room/ # Reusable infrastructure
│ │ ├── types.ts # Base types (BaseSession, BaseRoom, etc.)
│ │ └── helpers.ts # Reusable functions
│ │
│ └── games/ # Game-specific code
│ ├── types.ts # Game domain types (Player, Turn, etc.)
│ ├── gameMetadata.ts # Metadata type definitions
│ ├── gameAdapter.ts # Backward compatibility
│ └── helpers.ts # Game-specific logic
client/
└── src/
├── MediaControl.tsx # Reusable WebRTC component
└── PlayerList.tsx # Uses MediaControl + game metadata
Best Practices
DO:
✅ Keep infrastructure layer generic and reusable ✅ Put all application logic in metadata ✅ Use type parameters for extensibility ✅ Document what's reusable vs. application-specific ✅ Test infrastructure independently
DON'T:
❌ Mix application logic into infrastructure types ❌ Hard-code application fields in base types ❌ Duplicate infrastructure code per application ❌ Bypass metadata layer in new code ❌ Create circular dependencies between layers
Next Steps
- Immediate: Use adapter layer for backward compatibility
- Short-term: Migrate high-traffic functions to use metadata
- Long-term: Remove adapters, use pure metadata architecture
- Future: Extract infrastructure into separate npm package
See Also
- MEDIACONTROL_API.md - WebRTC signaling protocol
- server/routes/room/types.ts - Infrastructure types
- server/routes/games/gameMetadata.ts - Game metadata types
- server/routes/games/gameAdapter.ts - Compatibility adapter