Real-time collaborative platform โ video calls, multiplayer games, and synchronized watch parties in one.
PlayTogether is a Google Meet-inspired collaboration platform that layers real-time multiplayer mini-games and synchronized watch parties directly onto a video call, without leaving the room. Built with a fully decoupled monorepo architecture and a host-authoritative synchronization model, it keeps all participants in lockstep across high-latency connections.
| Feature | Description |
|---|---|
| ๐น Video Calls | Full multi-party WebRTC video via LiveKit SFU with speaking indicators |
| ๐ฎ 5 Mini-Games | Tic Tac Toe, Memory Match, Quick Math, Rock Paper Scissors, Word Scramble |
| ๐บ Watch Party | Host-synchronized YouTube/video playback with queue and reactions |
| โก Real-Time Sync | Socket.io 4 for game state, watch sync, queue management, and reactions |
| โฟ Accessible | WCAG 2.1 AA compliant โ full keyboard nav, aria-labels, focus rings |
| ๐ฑ Responsive | Mobile landscape-aware with iOS safe area inset support |
graph TB
subgraph CLIENT["CLIENT โ React + Vite"]
direction TB
VG["๐น VideoGrid\n(LiveKit)"]
HP["๐๏ธ HubPanel"]
CB["โ๏ธ ControlBar / TopBar"]
SS["๐ SnackbarStack"]
HP --> Games["๐ฎ Games Panel"]
HP --> Watch["๐บ Watch Panel"]
subgraph HOOKS["Custom Hooks / Contexts"]
US[useSocket.js]
UG[useGameSync.js]
UW[useWatchSync.js]
RC[RoomContext]
MC[MediaContext]
end
end
subgraph SERVER["SERVER โ Express + Node.js"]
direction TB
SR["๐ SocketRouter\n(Mediator)"]
RM["๐ RoomManager\n(Singleton)"]
LK_SVC["๐ฅ LiveKitService"]
subgraph GAME_ENGINE["Game Engine"]
BG[BaseGame\nAbstract]
BG --> TTT[TicTacToe]
BG --> MM[MemoryMatch]
BG --> QM[QuickMath]
BG --> RPS[RockPaperScissors]
BG --> WS[WordScramble]
end
subgraph WATCH_ENGINE["Watch Engine"]
WSE["WatchSyncEngine\n- host-authoritative\n- drift detection\n- queue management"]
end
SR --> RM
SR --> GAME_ENGINE
SR --> WATCH_ENGINE
SR --> LK_SVC
end
subgraph LIVEKIT["LiveKit Cloud โ SFU"]
LK["WebRTC / ICE / TURN / STUN"]
end
CLIENT -- "Socket.io ws://" --> SERVER
CLIENT -- "REST http://" --> SERVER
CLIENT -- "WebRTC" --> LIVEKIT
SERVER -- "LiveKit SDK" --> LIVEKIT
The server-side game engine is a textbook exercise in classical OOP and SOLID principles.
All five games extend a common BaseGame abstract class that defines a rigid, polymorphic interface:
// server/src/game/BaseGame.js
class BaseGame {
start(players) { throw new Error('Not implemented'); }
handleMove(playerId, move) { throw new Error('Not implemented'); }
getState() { throw new Error('Not implemented'); }
isGameOver() { throw new Error('Not implemented'); }
reset() { throw new Error('Not implemented'); }
}Each game (e.g., TicTacToe, MemoryMatch) overrides these methods with their own rules. The SocketRouter treats every game identically through this shared interface โ it never checks if game === 'tic-tac-toe'.
The GameFactory class encapsulates object creation, decoupling the socket router from concrete game implementations. Adding a new game requires zero changes to the router:
// server/src/game/GameFactory.js
class GameFactory {
static create(gameType) {
const games = {
'tic-tac-toe': () => new TicTacToe(),
'memory-match': () => new MemoryMatch(),
'quick-math': () => new QuickMath(),
'rock-paper-scissors': () => new RockPaperScissors(),
'word-scramble': () => new WordScramble(),
};
if (!games[gameType]) throw new Error(`Unknown game: ${gameType}`);
return games[gameType]();
}
}RoomManager is instantiated once at server startup and injected into the SocketRouter. It maintains a single authoritative Map<roomCode, Room> preventing any split-brain state across concurrent socket connections:
// server/src/services/RoomManager.js
class RoomManager {
constructor() {
this.rooms = new Map(); // Single source of truth
}
createRoom(code) { ... }
addParticipant(code, participant) { ... }
removeParticipant(code, socketId) { ... }
}SocketRouter acts as a central event mediator. No game handler or watch handler ever talks to another handler directly โ all communication is mediated through the router:
// server/src/socket/SocketRouter.js
class SocketRouter {
constructor(io, roomManager) {
this.io = io;
this.roomManager = roomManager;
}
initializeHandlers(socket) {
socket.on('room:join', (d) => this.handleRoomJoin(socket, d));
socket.on('game:start', (d) => this.handleGameStart(socket, d));
socket.on('game:move', (d) => this.handleGameMove(socket, d));
socket.on('watch:play', (d) => this.handleWatchPlay(socket, d));
socket.on('watch:react', (d) => this.handleWatchReact(socket, d));
// ...
}
}Each game implements its own move validation and state computation strategy. The SocketRouter calls game.handleMove(playerId, move) โ the concrete strategy for what that means differs per game completely independently:
- TicTacToe: validates cell is empty, checks 8 win conditions
- MemoryMatch: validates card is unflipped, resolves pair matches
- QuickMath: validates answer string against computed solution
Socket.io's event emitter architecture is a natural Observer implementation. Room participants subscribe to events like game:state-update. When the server calls io.to(roomCode).emit(...), all observers receive the state delta simultaneously:
sequenceDiagram
participant S as Server
participant P1 as Player 1
participant P2 as Player 2
P1->>S: game:move (cellIndex: 4)
S-->>S: game.handleMove() - validate & compute
S-->>P1: game:state-update
S-->>P2: game:state-update
| Principle | Where Applied |
|---|---|
| Single Responsibility | RoomManager manages rooms, WatchSyncEngine manages watch state, LiveKitService manages tokens โ each class has exactly one reason to change |
| Open/Closed | New games are added by creating a new BaseGame subclass and a GameFactory entry. Existing code is never modified |
| Liskov Substitution | Any BaseGame subclass can replace another in the SocketRouter without breaking behavior โ all honor the same interface contract |
| Interface Segregation | Clients receive only the state slices they need (game:state-update vs watch:sync) rather than one monolithic broadcast |
| Dependency Inversion | SocketRouter depends on the RoomManager abstraction injected at construction, not a new RoomManager() call internally |
The watch party uses a host-authoritative model. Only the host's play/pause/seek events propagate to other viewers. This prevents desync from concurrent conflicting commands:
sequenceDiagram
participant H as Host Browser
participant S as Server
participant G as Guest Browser
H->>S: watch:play { currentTime: 42.3 }
S-->>G: watch:play { currentTime: 42.3 }
G-->>G: setPlaying(true), seek(42.3)
Note over S,G: Every 5s โ drift detection heartbeat
H->>S: watch:sync { currentTime: 47.1 }
S-->>G: watch:sync { currentTime: 47.1 }
G-->>G: delta > 1s? โ auto-seek to host
Game moves follow a server-authoritative flow:
sequenceDiagram
participant C as Client
participant S as Server
participant R as Room
C->>S: game:move { cellIndex: 3 }
S-->>S: game.handleMove() - validate
alt Valid move
S-->>R: game:state-update { newState }
else Invalid / out of turn
S-->>C: game:error { reason }
end
All clients render the server-confirmed state โ never the client's optimistic version. This prevents desync and cheating simultaneously.
Emoji reactions are intentionally not persisted in WatchSyncEngine. They are pure fire-and-forget broadcasts: the server receives watch:react, immediately fans it out to the room, and forgets. This minimizes server memory and eliminates the need for cleanup logic.
| Technology | Role |
|---|---|
| React 19 | UI component tree, state management via hooks |
| Vite 8 | Lightning-fast dev server and ESM bundler |
| Tailwind CSS 4 | Utility-first styling and responsive breakpoints |
| Framer Motion | Floating reaction animations, AnimatePresence list transitions |
| Socket.io-client | Real-time bidirectional WebSocket communication |
| @livekit/components-react | Pre-built WebRTC video grid components |
| react-player | Universal YouTube / video URL player abstraction |
| Technology | Role |
|---|---|
| Node.js + Express | HTTP server, REST API endpoints |
| Socket.io 4 | WebSocket server, room-scoped event broadcasting |
| livekit-server-sdk | LiveKit JWT access token generation |
| CORS | Cross-origin request handling for clientโserver separation |
| Service | Role |
|---|---|
| LiveKit Cloud | Multi-party SFU for WebRTC โ handles TURN/STUN/ICE negotiation |
| Vercel | Frontend static hosting with automatic CI/CD from GitHub |
| Render | Backend Node.js hosting with persistent WebSocket support |
PlayTogether/
โโโ client/ # React + Vite frontend
โ โโโ public/
โ โ โโโ favicon.svg
โ โโโ src/
โ โ โโโ main.jsx # Entry point
โ โ โโโ App.jsx # Router: Landing / PreJoin / Room
โ โ โโโ index.css # Design system tokens + global CSS
โ โ โโโ contexts/
โ โ โ โโโ RoomContext.jsx # Room state (participants, host, hub)
โ โ โ โโโ MediaContext.jsx # Camera/mic preferences
โ โ โ โโโ SnackbarContext.jsx # Global notification queue
โ โ โโโ hooks/
โ โ โ โโโ useSocket.js # Socket.io lifecycle + event subscription
โ โ โ โโโ useGameSync.js # Game state machine via socket
โ โ โ โโโ useWatchSync.js # Watch playback sync + queue
โ โ โโโ components/
โ โ โโโ ui/ # Design system primitives
โ โ โ โโโ ControlButton.jsx
โ โ โ โโโ Avatar.jsx
โ โ โ โโโ Tooltip.jsx
โ โ โ โโโ Snackbar.jsx
โ โ โโโ shell/ # App chrome
โ โ โ โโโ LandingPage.jsx
โ โ โ โโโ PreJoinScreen.jsx
โ โ โ โโโ RoomPage.jsx # Main in-room layout
โ โ โ โโโ HubPanel.jsx # Games / Watch panel container
โ โ โ โโโ HubSwitcher.jsx
โ โ โ โโโ ParticipantsDrawer.jsx
โ โ โโโ video/
โ โ โ โโโ VideoGrid.jsx # LiveKit participant layout
โ โ โ โโโ VideoTile.jsx # Single participant tile + speaking ring
โ โ โโโ game/
โ โ โ โโโ TicTacToeBoard.jsx
โ โ โ โโโ MemoryMatchBoard.jsx
โ โ โ โโโ QuickMathBoard.jsx
โ โ โ โโโ RPSBoard.jsx
โ โ โ โโโ WordScrambleBoard.jsx
โ โ โ โโโ ScoreHeader.jsx
โ โ โ โโโ GameSelector.jsx
โ โ โ โโโ GameOverOverlay.jsx
โ โ โโโ watch/
โ โ โโโ WatchPanel.jsx
โ โ โโโ VideoPlayer.jsx
โ โ โโโ ReactionBar.jsx
โ โ โโโ FloatingReactions.jsx
โ โ โโโ QueuePanel.jsx
โ โโโ index.html
โ โโโ vite.config.js
โ โโโ package.json
โ
โโโ server/ # Node.js + Express backend
โโโ src/
โ โโโ index.js # Server entry, CORS, Socket.io init
โ โโโ config/
โ โ โโโ index.js # Env vars (PORT, LIVEKIT_*)
โ โโโ routes/
โ โ โโโ roomRoutes.js # POST /api/rooms, GET /api/rooms/:code
โ โ โโโ livekitRoutes.js # POST /api/livekit/token
โ โโโ services/
โ โ โโโ RoomManager.js # Room CRUD + participant tracking (Singleton)
โ โ โโโ LiveKitService.js # JWT token generation
โ โโโ game/
โ โ โโโ BaseGame.js # Abstract game interface
โ โ โโโ GameFactory.js # Factory pattern
โ โ โโโ TicTacToe.js
โ โ โโโ MemoryMatch.js
โ โ โโโ QuickMath.js
โ โ โโโ RockPaperScissors.js
โ โ โโโ WordScramble.js
โ โโโ sync/
โ โ โโโ WatchSyncEngine.js # Host-authoritative video sync + queue
โ โโโ socket/
โ โ โโโ SocketRouter.js # Mediator: all socket event dispatch
โ โโโ utils/
โ โโโ generateRoomCode.js
โโโ package.json
| Event | Direction | Payload | Description |
|---|---|---|---|
room:join |
Client โ Server | { roomCode, displayName } |
Join or create a room |
room:leave |
Client โ Server | โ | Leave current room |
room:joined |
Server โ Client | { participants, hostId, activeHub } |
Emitted to joiner on success |
room:participant-joined |
Server โ Room | { participant, participants } |
Broadcast to others on new join |
room:participant-left |
Server โ Room | { participants, hostId } |
Broadcast on participant disconnect |
room:error |
Server โ Client | { message } |
Error feedback (room full, invalid code) |
room:set-hub |
Client โ Server | { activeHub } |
Host switches active hub |
room:hub-updated |
Server โ Room | { activeHub } |
Broadcast hub switch to all participants |
| Event | Direction | Payload | Description |
|---|---|---|---|
game:start |
Client โ Server | { gameType } |
Host starts a game |
game:move |
Client โ Server | { move } |
Player submits a move |
game:state-update |
Server โ Room | { gameState } |
Authoritative state broadcast |
game:reset |
Client โ Server | โ | Reset current game |
game:exit |
Client โ Server | โ | Exit game back to lobby |
| Event | Direction | Payload | Description |
|---|---|---|---|
watch:play |
Client โ Server | { currentTime } |
Host plays video |
watch:pause |
Client โ Server | { currentTime } |
Host pauses video |
watch:seek |
Client โ Server | { currentTime } |
Host seeks to timestamp |
watch:load-url |
Client โ Server | { url } |
Host loads a new video URL |
watch:sync |
Server โ Room | { isPlaying, currentTime, url } |
Sync heartbeat to all guests |
watch:queue-add |
Client โ Server | { url, addedBy } |
Add URL to shared queue |
watch:queue-remove |
Client โ Server | { id } |
Remove item from queue |
watch:queue-dequeue |
Client โ Server | โ | Host plays next queue item |
watch:react |
Client โ Server | { emoji } |
Send emoji reaction |
PlayTogether follows Material Design 3 semantics with Google Meet's dark-first video surface aesthetic.
--color-video-surface: #202124; /* Dark video background */
--color-control-bar: #3C4043; /* Control bar surface */
--color-blue: #1857D9; /* Primary brand + focus ring */
--color-green: #0F9D58; /* Speaking ring + success */
--color-red: #D93025; /* Leave call + danger */
--color-cyan: #00BCD4; /* Watch sync badge */
--color-amber: #F29900; /* Out-of-sync warning */| Animation | Duration | Easing |
|---|---|---|
| Speaking ring pulse | 1200ms | ease-in-out infinite |
| Score bump | 400ms | cubic-bezier(0.34, 1.56, 0.64, 1) |
| Card hover lift | 150ms | ease-out |
| Hub panel slide | 300ms | ease-standard |
| Floating reaction | 1500ms | ease-out |
| Snackbar slide-in | 250ms | ease-decelerate |
- Node.js โฅ 18
- A LiveKit Cloud account (free tier works)
git clone https://github.com/vingoel26/PlayTogether.git
cd PlayTogether
npm installserver/.env
PORT=3001
CLIENT_URL=http://localhost:5173
LIVEKIT_API_KEY=your_livekit_api_key
LIVEKIT_API_SECRET=your_livekit_api_secret
LIVEKIT_URL=wss://your-project.livekit.cloudclient/.env
VITE_SERVER_URL=http://localhost:3001
VITE_LIVEKIT_WS_URL=wss://your-project.livekit.cloud# Terminal 1 โ Backend
cd server && npm run dev
# Terminal 2 โ Frontend
cd client && npm run devOpen http://localhost:5173 in two browser tabs and create/join a room!
cd client
npm run build
# or connect GitHub repo to Vercel dashboardSet environment variables in Vercel dashboard:
VITE_SERVER_URLโ your Render backend URLVITE_LIVEKIT_WS_URLโ your LiveKit Cloud WSS URL
- Create a Web Service on Render
- Connect your GitHub repo, set root directory to
server/ - Build command:
npm install - Start command:
npm start - Set all env vars from
server/.env
Important: Render's free tier supports persistent WebSocket connections. Ensure the plan you select does not have request timeout limits shorter than your average session length.
- LiveKit tokens are short-lived JWTs generated server-side and never exposed in client code
- Room codes are 9-character random strings โ not guessable but not secret (share with intent)
- Host authority: Only the host can start games, change hubs, or control video playback. All guard checks live server-side in
SocketRouter.js - Max room size: Enforced server-side at 8 participants โ the 9th connection is rejected before any state mutation
- CORS: Configured to only allow requests from the known client origin
- Auth: User accounts, persistent room history (Auth.js + PostgreSQL)
- Spectator Mode: Join a room in view-only mode without a video tile
- Screen Share: Leverage LiveKit's screen-share track for presentations
- Chat: Persistent room chat panel alongside the video call
- More Games: Chess, Wordle, Codenames word game integration