Skip to content

vingoel26/PlayTogether

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

31 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๐ŸŽฎ PlayTogether

Real-time collaborative platform โ€” video calls, multiplayer games, and synchronized watch parties in one.

React Vite Socket.io LiveKit Node.js Framer Motion


โœจ Overview

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.

Core Features

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

๐Ÿ—๏ธ System Architecture

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
Loading

๐Ÿง  OOP Design Patterns

The server-side game engine is a textbook exercise in classical OOP and SOLID principles.

Abstract Base Class & Polymorphism

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'.

Factory Pattern

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]();
  }
}

Singleton Pattern

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) { ... }
}

Mediator Pattern

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));
    // ...
  }
}

Strategy Pattern

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

Observer Pattern (via Socket.io)

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
Loading

๐Ÿ”„ SOLID Principles Mapping

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

โšก Real-Time Synchronization Design

Host-Authoritative Watch Sync

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
Loading

Game State Authority

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
Loading

All clients render the server-confirmed state โ€” never the client's optimistic version. This prevents desync and cheating simultaneously.

Ephemeral Reactions

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.


๐Ÿ“ฆ Tech Stack

Frontend

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

Backend

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

Infrastructure

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

๐Ÿ“ Project Structure

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

๐ŸŽฎ Socket.io Event Reference

Room Events

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

Game Events

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

Watch Events

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

๐ŸŽจ Design System

PlayTogether follows Material Design 3 semantics with Google Meet's dark-first video surface aesthetic.

Color Palette

--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 Spec

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

๐Ÿš€ Getting Started

Prerequisites

1. Clone & Install

git clone https://github.com/vingoel26/PlayTogether.git
cd PlayTogether
npm install

2. Environment Variables

server/.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.cloud

client/.env

VITE_SERVER_URL=http://localhost:3001
VITE_LIVEKIT_WS_URL=wss://your-project.livekit.cloud

3. Run Locally

# Terminal 1 โ€” Backend
cd server && npm run dev

# Terminal 2 โ€” Frontend  
cd client && npm run dev

Open http://localhost:5173 in two browser tabs and create/join a room!


๐ŸŒ Deployment

Frontend โ†’ Vercel

cd client
npm run build
# or connect GitHub repo to Vercel dashboard

Set environment variables in Vercel dashboard:

  • VITE_SERVER_URL โ†’ your Render backend URL
  • VITE_LIVEKIT_WS_URL โ†’ your LiveKit Cloud WSS URL

Backend โ†’ Render

  1. Create a Web Service on Render
  2. Connect your GitHub repo, set root directory to server/
  3. Build command: npm install
  4. Start command: npm start
  5. 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.


๐Ÿ” Security Considerations

  • 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

future scope

  • 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

Releases

No releases published

Packages

 
 
 

Contributors