Inspiration
Long-distance relationships make shared experiences hard. Existing watch-party sites let you sync video but they are just browser tabs with a chat sidebar. You don't feel like you're together. I wanted the feeling of a real cinema: walking in together, picking seats, the lights dimming, the screen filling your vision. A spatial memory, not a browser tab.
What it does
CineWorld is a 3D virtual cinema you visit in your browser. You walk in with WASD, look around with your mouse, and click any of the 63 seats to sit down. Your avatar animates - walking, sitting, idle. Paste a YouTube link and hit enter - everyone in the room sees it start at the exact same frame. Pause, play, seek - all synchronized. Chat appears above avatars and in a log. System messages announce who joined, who played what, who left. No screen sharing, no browser extensions, just a link you send to a friend.
How we built it
Client: React 19 with @react-three/fiber and Three.js. The theater is a GLB model. Seats use instanced rendering for performance. Characters are GLTF with compressed animations. A single persistent WebSocket connects at app startup and survives scene remounts. Input is centralized - one controller owns all keyboard and mouse events, components subscribe to named actions.
Server: Go 1.22. One goroutine owns all room state - players, seats, video, chat. Every action (move, sit, chat, play) is a message sent to that goroutine via channel. It processes them sequentially. Zero locks, zero races. Each connection gets two goroutines for reading and writing that exit cleanly on disconnect. Identity is server-assigned with crypto/rand. The protocol package is the trust boundary - it decodes and validates every frame before the room sees it.
Video sync: The server owns the timeline. It tracks playhead position from wall-clock time. Clients only report their real position periodically so the server can correct drift from buffering. Corrections apply silently for the next joiner.
Challenges we ran into
Video drift: The server extrapolates position from time. Buffering breaks this - your video pauses but the clock doesn't. Over a two hour movie the error grows without bound. Fixed by having playing clients report real position. Server accepts only if within 10 seconds of expected (plausible drift, not a seek). Corrections don't broadcast - they take effect for the next person who joins.
Seat conflicts: 63 seats, many people clicking at once. Classic race condition. Solved by making sit/stand commands messages to the room goroutine. Sequential by design, no locks needed.
YouTube lookup storms: Rapid play commands could spawn unlimited HTTP requests. Solved with a single background worker and a one-slot queue that keeps only the newest request.
Input mode confusion: Gameplay, pause, chat - components kept checking mode differently. Unified into one store. Input controller owns all events. Leaving gameplay clears held keys so you don't keep walking when you alt-tab.
Mobile performance: 63 animated characters plus theater overwhelmed phone GPUs. Instanced seats, compressed animations, downscaled textures, and a friendly desktop-only page for mobile visitors.
Accomplishments that we're proud of
- True multiplayer cinema feel - not a browser tab with chat sidebar
- Server-authoritative sync - everyone sees frame N at time T, drift corrected automatically
- Zero mutexes in the room - all state mutations through channels
- Runs on a $5 VPS handling 64 concurrent users at 20 Hz
- Clean architecture - protocol validates at trust boundary, room trusts its inputs
- Graceful shutdown - connections drain, room saves state, no goroutine leaks
What we learned
The hard problems in multiplayer aren't graphics - they're synchronization. A single process owning all state through message passing eliminates entire categories of bugs. JSON over WebSocket is fast enough at 20 Hz for 64 clients. The YouTube IFrame API fights you - the server must own the timeline, clients only report position for drift correction. One centralized input controller prevents ghost movement on focus loss. Instanced rendering and asset compression make 63 characters viable on integrated graphics.
What's next for CineWorld
Support platforms beyond YouTube - Vimeo, Twitch, direct MP4/WebM URLs, and local file playback. Each needs a unified playback abstraction so the room logic stays platform-agnostic while the client adapts to different player APIs.
Log in or sign up for Devpost to join the conversation.