WatchTogether
Full System Architecture & Tech Stack
A cross-platform real-time watch party app with gamification, guild systems, event-based collectables, a leaderboard, and an ad marketplace. Targets 10k initial users across Web, iOS, Android, Windows, Mac, and Linux — all sharing one unified backend.
Platforms
Web · iOS · Android · Windows · macOS · Linux
Initial Scale
~10k users · single region · horizontally scalable
Core Loop
Watch together → earn XP → level up → compete on leaderboard
Revenue
Free tier with ads · Premium subscription · Ad marketplace
Feature Map
🎬 Watch Rooms
- YouTube sync
- Google Drive video
- Spotify (audio)
- Any web URL (iframe)
- Public / Friends / Private
- Voice chat (WebRTC)
- Live chat
- Invite system
⚔️ Guilds
- Create / join guilds
- Max 10 members
- Guild watch hours
- Guild leaderboard
- Top 3 guilds → free premium
- Guild chat
🎮 Gamification
- XP system
- Levels 1–10
- Daily login bonus
- Watch hour tracking
- Friend & chat XP
- Individual leaderboard
- Top 3 → 3 months premium
🏆 Events
- Time-limited events
- Task completion
- Collectable badges
- Profile decorations
- Video-watch events
- Business promotions
👤 Profiles
- Avatar / bio / username
- Watch stats (week/month/year/all)
- Badge showcase
- Level & XP display
- Join date
- Friend list
💰 Ads
- Pre/post room video ads
- Business dashboard
- Pay-per-view upload
- XP reward for watching
- Title / avatar reward
- Free tier only
Frontend Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Monorepo | Turborepo | Fast incremental builds, shared packages, one repo for all platforms |
| Web app | Next.js 14 (App Router) | SSR for SEO on landing/profile pages, RSC for performance, TypeScript native |
| Mobile | React Native + Expo SDK 51 | Single codebase for iOS + Android, OTA updates, Expo Router for navigation |
| Desktop | Tauri 2.0 | Wraps the web app in a Rust shell. Tiny bundle (~5MB vs Electron's 150MB), fast, cross-platform |
| Web styling | Tailwind CSS v4 | Utility-first, design tokens, dark mode, consistent spacing |
| Mobile styling | NativeWind v4 | Tailwind syntax for React Native — same class names across platforms |
| Shared UI | @app/ui package | Component library built in React (web uses react-dom, mobile uses react-native) |
| State (global) | Zustand | Tiny, no boilerplate, works identically on all platforms |
| Server state | TanStack Query v5 | Caching, background refetch, optimistic updates — works on web + native |
| Forms | React Hook Form + Zod | Validation shared between client and server via same Zod schemas |
| Real-time client | Socket.io-client | Managed WebSocket, auto-reconnect, works in all envs |
| Video (YT) | YouTube IFrame API | Official, pause/play/seek control, event callbacks |
| Voice chat | Agora RTC SDK or LiveKit | WebRTC abstraction — Agora free tier for 10k MAU is enough to start |
| Push (mobile) | Expo Notifications | FCM + APNs via one API, no separate server setup |
| Push (web) | Web Push API | Native browser, no third party |
| Analytics | PostHog | Open-source product analytics, self-hostable, session recording |
| Error tracking | Sentry | Works across all platforms, source maps, alerts |
Backend Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Runtime | Node.js 22 LTS | Stable, vast ecosystem, same language as frontend = shared types/schemas |
| HTTP framework | Fastify v5 | Fastest Node.js framework (2× Express), TypeScript native, schema validation built-in |
| WebSocket | Socket.io v4 | Rooms, namespaces, fallback to long-poll, Redis adapter for multi-instance |
| Primary DB | PostgreSQL 16 via Supabase | Managed, realtime subscriptions, row-level security, free tier generous |
| ORM | Drizzle ORM | TypeScript-first, no magic, fast, schema as code, generates migrations |
| Cache / Pub-Sub | Redis 7 via Upstash | Session store, Socket.io adapter (cross-server events), leaderboard sorted sets, rate limiting |
| Auth | Supabase Auth | OAuth (Google, Discord, Apple), JWT, refresh tokens, email/password, MFA |
| File Storage | Supabase Storage + Cloudflare R2 | Profile pics / badges on Supabase; large ad videos on R2 (S3-compatible, cheap egress) |
| Background jobs | BullMQ (Redis queue) | Leaderboard snapshots, XP rewards, email dispatch, ad delivery scheduling |
Resend | Developer-friendly, React Email templates, generous free tier | |
| Payments | Stripe | Subscriptions (Premium), one-time (ad purchases), webhooks |
| API type-safety | tRPC v11 | End-to-end type safety without codegen — Next.js + React Native both consume same router |
| Validation | Zod | Shared schemas on client and server, TypeScript inference, Drizzle integration |
| Rate limiting | @fastify/rate-limit + Redis | Per-user, per-IP, per-endpoint |
| Logging | Pino (built into Fastify) | Structured JSON logs, ships to Logtail/Axiom |
Infrastructure
| Service | Provider | Notes |
|---|---|---|
| Web hosting | Vercel | Zero-config Next.js, edge CDN, preview deployments per PR |
| API server | Railway | Docker-based, auto-scales, $5/month starter — upgrade to 2+ replicas when needed |
| Database | Supabase | Managed Postgres, Auth, Storage, Realtime. Free tier: 500MB DB, 1GB storage |
| Redis | Upstash | Serverless Redis, pay-per-request, global edge — free tier: 10k commands/day |
| Video/File CDN | Cloudflare R2 + CDN | Zero egress fees, serves ad videos fast globally |
| DNS + DDoS | Cloudflare | Free tier covers 10k users fine, WAF, bot protection |
| CI/CD | GitHub Actions | Test → build → deploy pipeline on merge to main |
| Secrets | Railway Secrets + Vercel env | Never in code. Rotation policy per env |
| Monitoring | Better Uptime | Status page + alerts, free tier for 1 monitor |
| Mobile OTA | Expo EAS Update | Push JS bundle updates without App Store review |
| Mobile builds | Expo EAS Build | Cloud builds for iOS (.ipa) and Android (.aab) |
Monorepo Structure
watchparty/ ← Turborepo root ├── apps/ │ ├── web/ ← Next.js 14 (Web + Tauri shell) │ ├── mobile/ ← React Native + Expo │ ├── api/ ← Fastify + tRPC + Socket.io │ └── desktop/ ← Tauri 2.0 (wraps web app) │ ├── packages/ │ ├── @app/ui ← Shared component library │ ├── @app/schemas ← Zod schemas (shared client+server) │ ├── @app/db ← Drizzle schema + migrations │ ├── @app/types ← TypeScript types shared everywhere │ ├── @app/config ← ESLint, Prettier, TS configs │ └── @app/utils ← Pure utility functions │ ├── turbo.json ├── package.json └── pnpm-workspace.yaml ← Use pnpm for workspace efficiency
@app/schemas. The API validates with them server-side via Fastify. The frontend uses the same schemas for form validation. tRPC generates client types from the router — zero manual type maintenance.
System Architecture
Auth & Session System
OAuth Flow
Session Architecture
// Redis key structure "session:{userId}" → { sessionId, deviceInfo, createdAt, expiresAt } "session:{userId}:socket" → socketId // current WS connection // On new login: async function createSession(userId, deviceInfo) { const existing = await redis.get(`session:${userId}`); if (existing) { // Emit 'force_logout' to their active socket, THEN delete await io.to(existing.socketId).emit('force_logout', { reason: 'New login' }); await redis.del(`session:${userId}`); } const sessionId = generateSecureId(); await redis.setex(`session:${userId}`, 86400 * 30, { sessionId, deviceInfo }); return sessionId; }
JWT Strategy
- Access token: 15 min expiry — short-lived, stored in memory only (never localStorage)
- Refresh token: 30 days — stored in httpOnly cookie (web) or SecureStorage (mobile)
- Rotation: Each refresh call issues a new refresh token and invalidates the old one
- Socket auth: JWT verified on connection handshake, re-verified on reconnect
Real-time Architecture
Socket.io Room Structure
// Namespaces io.of('/rooms') → video sync, room chat, roster io.of('/guilds') → guild chat, guild events io.of('/global') → friend online status, notifications, leaderboard updates // Socket.io room = watch room socket.join(`room:${roomId}`) // Events emitted in a room: 'video:play' → { timestamp, serverTime } 'video:pause' → { timestamp } 'video:seek' → { timestamp } 'video:buffer' → { userId } // show buffer spinner for that user 'chat:message' → { userId, content, createdAt } 'room:join' → { user } 'room:leave' → { userId } 'room:kick' → { userId } 'room:mic_toggle' → { enabled }
Redis Adapter — Multi-Instance Sync
import { createAdapter } from '@socket.io/redis-adapter'; const pubClient = new Redis(process.env.REDIS_URL); const subClient = pubClient.duplicate(); io.adapter(createAdapter(pubClient, subClient)); // Now io.to('room:xyz').emit() works across ALL server instances
Video Sync Engine
How it works
// Server maintains room state in Redis interface RoomVideoState { url: string; platform: 'youtube' | 'drive' | 'spotify' | 'web'; isPlaying: boolean; position: number; // seconds at last event lastEventAt: number; // Date.now() when position was set leaderId: string; // only leader controls playback } // When a client seeks/plays/pauses: socket.on('video:play', ({ position }) => { const state = { isPlaying: true, position, lastEventAt: Date.now() }; redis.set(`room:${roomId}:video`, JSON.stringify(state)); io.to(`room:${roomId}`).emit('video:play', { ...state }); }); // On client: calculate actual current position accounting for latency function getServerPosition(state: RoomVideoState): number { if (!state.isPlaying) return state.position; const elapsed = (Date.now() - state.lastEventAt) / 1000; return state.position + elapsed; } // If client drift > 1.5s → hard seek. If 0.5–1.5s → speed adjust (1.05x or 0.95x) const HARD_SEEK_THRESHOLD = 1.5; const SOFT_CORRECT_THRESHOLD = 0.5;
Platform Integration
| Platform | Method | Control API | Notes |
|---|---|---|---|
| YouTube | IFrame Embed | YT.Player — seekTo(), playVideo() | Most reliable. Needs user interaction to autoplay. |
| Google Drive | Drive embed iframe | PostMessage API (limited) | Can sync play/pause. Seek is limited — use server position on join. |
| Spotify | Web Playback SDK | Spotify Connect API | Requires Spotify Premium on all listeners. Syncs via player state. |
| Web URL | Sandboxed iframe | None (display only) | Show URL, all users load independently. Chat-only sync. |
Database Schema
Full PostgreSQL schema using Drizzle ORM. All tables include created_at and updated_at timestamps automatically.
Core Tables Overview
-- USERS users id uuid PK username text UNIQUE NOT NULL display_name text email text UNIQUE NOT NULL avatar_url text bio text (max 160) level int DEFAULT 1 xp int DEFAULT 0 is_premium boolean DEFAULT false premium_until timestamptz is_verified boolean DEFAULT false created_at timestamptz -- FRIENDS friend_requests from_user_id uuid FK → users to_user_id uuid FK → users status enum('pending','accepted','rejected') PRIMARY KEY (from_user_id, to_user_id) -- GUILDS guilds id uuid PK name text UNIQUE NOT NULL description text avatar_url text owner_id uuid FK → users max_members int DEFAULT 10 is_public boolean DEFAULT true total_watch_hours float DEFAULT 0 guild_members guild_id uuid FK → guilds user_id uuid FK → users role enum('leader','officer','member') joined_at timestamptz PRIMARY KEY (guild_id, user_id) -- WATCH ROOMS rooms id uuid PK name text leader_id uuid FK → users platform enum('youtube','drive','spotify','web') content_url text visibility enum('public','friends','private') mic_enabled boolean DEFAULT false max_members int DEFAULT 50 is_active boolean DEFAULT true guild_id uuid FK → guilds (nullable) room_members room_id uuid FK → rooms user_id uuid FK → users is_muted boolean DEFAULT false joined_at timestamptz PRIMARY KEY (room_id, user_id) room_messages id uuid PK room_id uuid FK → rooms user_id uuid FK → users content text NOT NULL (max 500) created_at timestamptz -- WATCH HOURS (aggregated per user) watch_sessions id uuid PK user_id uuid FK → users room_id uuid FK → rooms started_at timestamptz ended_at timestamptz duration_secs int ← computed on session end -- GAMIFICATION xp_log id uuid PK user_id uuid FK → users amount int reason enum('watch','login','chat','friend','event','ad_watch') created_at timestamptz -- EVENTS events id uuid PK title text description text starts_at timestamptz ends_at timestamptz is_sponsored boolean DEFAULT false sponsor_name text event_tasks id uuid PK event_id uuid FK → events title text description text xp_reward int badge_id uuid FK → badges (nullable) user_event_progress user_id uuid FK → users task_id uuid FK → event_tasks completed_at timestamptz PRIMARY KEY (user_id, task_id) -- BADGES / COLLECTABLES badges id uuid PK name text description text image_url text rarity enum('common','rare','epic','legendary') event_id uuid FK → events (nullable) is_active boolean user_badges user_id uuid FK → users badge_id uuid FK → badges earned_at timestamptz is_displayed boolean DEFAULT false ← on profile showcase PRIMARY KEY (user_id, badge_id) -- LEADERBOARD CACHE (rebuilt weekly by BullMQ job) leaderboard_snapshots id uuid PK type enum('individual','guild') period enum('weekly','monthly','alltime') rank int entity_id uuid ← user_id or guild_id watch_hours float snapshot_at timestamptz -- ADS ads id uuid PK advertiser_id uuid FK → users title text video_url text xp_reward int DEFAULT 0 reward_title text ← cosmetic title unlocked on watch status enum('pending','active','paused','done') impressions int DEFAULT 0 paid_amount numeric(10,2) ad_impressions ad_id uuid FK → ads user_id uuid FK → users watched_pct int ← 0–100, must be 80%+ to earn XP rewarded boolean created_at timestamptz PRIMARY KEY (ad_id, user_id) ← one per user per ad
Entity Relationship Diagram
Room System
Room Creation Settings
| Setting | Options | Default |
|---|---|---|
| Visibility | Public (anyone) · Friends only · Private (invite) | Friends only |
| Max members | 2 – 50 | 10 |
| Microphone | Enabled / Disabled (leader can toggle live) | Disabled |
| Playback control | Leader only · Any member | Leader only |
| Guild-linked | Yes (visible to guild) / No | No |
| Chat | All · Mods only · Off | All |
In-Room Leader Powers
- Kick member — removes from room
- Mute member — server-side mute flag
- Transfer leadership
- Toggle global mic — enables/disables voice for everyone
- Change content — switch video URL
- Lock room — prevents new joins
- Slow chat mode — 5s between messages
Invite System
// Invite link: time-limited, single-use or multi-use GET /api/rooms/:id/invite → { code: "abc123", expires_in: 3600 } // Friend invite: via push notification + in-app POST /api/rooms/:id/invite-user → { userId }
Guild System
Guild Roles
| Role | Permissions |
|---|---|
| Leader | All permissions, disband guild, transfer ownership, manage all members |
| Officer | Invite/kick members, manage guild rooms, create guild events |
| Member | Participate in guild rooms, view guild leaderboard, chat |
Guild Watch Hours Calculation
// BullMQ job runs every hour (and on session end) async function updateGuildWatchHours(guildId: string) { const result = await db.query(` SELECT SUM(ws.duration_secs) / 3600.0 as total_hours FROM watch_sessions ws JOIN guild_members gm ON ws.user_id = gm.user_id WHERE gm.guild_id = $1 `, [guildId]); await db.update(guilds).set({ total_watch_hours: result.total_hours }) .where(eq(guilds.id, guildId)); }
Leaderboard Reset & Rewards
// Weekly cron job (BullMQ scheduled) — runs every Monday 00:00 UTC 1. Snapshot current rankings → leaderboard_snapshots table 2. Top 3 guilds → set is_premium = true, premium_until = now + 30 days for all members 3. Top 3 individuals → set is_premium = true, premium_until = now + 90 days 4. Send push notifications to winners 5. Reset weekly counters (do NOT reset all-time)
Gamification System
XP Sources
| Action | XP | Cooldown |
|---|---|---|
| Daily login | +50 XP | Once per calendar day |
| Watch 1 hour | +100 XP | Per hour, max 5 hours/day |
| Watch party (with ≥2 friends) | +20 XP bonus/hr | Per hour in party |
| Send 10 chat messages | +15 XP | Max 3× per day |
| Add a friend (both users get XP) | +25 XP | Per new friend |
| Complete event task | Varies (+50–500) | Once per task |
| Watch ad (80%+ completion) | +30 XP | Once per ad |
| First watch session of day | +25 XP | Daily |
Level Thresholds (Level 1–10)
Level 1: 0 XP ← Starting Level 2: 500 XP Level 3: 1,500 XP Level 4: 3,500 XP Level 5: 7,500 XP Level 6: 15,000 XP Level 7: 27,500 XP Level 8: 45,000 XP Level 9: 70,000 XP Level 10: 100,000 XP ← Max (for v1)
users table and updated by a BullMQ worker after every XP gain. This prevents race conditions from multiple simultaneous XP events.
Event System
Event Types
Community Event
Time-limited challenge (e.g. "Watch 10 hours this weekend"). Complete tasks → earn exclusive badge. Created by admins.
Sponsored Event
Business pays to host an event. Users watch a branded video (hosted on R2) and earn XP + cosmetic reward. Tracked per-impression.
Seasonal Event
Holiday-themed (Christmas, Halloween). Special badges, profile frames. Auto-scheduled by admin.
Badge / Collectable System
Badges are SVG/PNG assets stored in Cloudflare R2, served via CDN. Users can display up to 5 badges on their profile. Rarity tiers:
Sponsored Video Event Flow
Ad System
When Ads Show
- Pre-room: Before joining a watch room (free users)
- Post-room: After leaving a watch room (free users)
- Event ads: Inside sponsored events (all users, but with XP incentive)
Ad Delivery Architecture
// Client requests an ad before joining a room GET /api/ads/next?placement=pre_room // Server selects ad: 1. Filter active ads (status = 'active') 2. Filter ads user hasn't seen yet (JOIN ad_impressions) 3. Sort by priority / remaining budget 4. Return video_url (signed Cloudflare R2 URL, expires 10min) // Client plays video, tracks % watched // At 80% completion, client calls: POST /api/ads/:id/impression → { watchedPct: 82, placement: 'pre_room' } // Server: 1. Verify 80%+ watched 2. Award XP via xp_log 3. Unlock reward_title if set 4. Increment ad.impressions 5. Queue BullMQ job to notify advertiser dashboard
Advertiser Pricing (example)
| Package | Price | Impressions |
|---|---|---|
| Starter | $50 | ~500 impressions |
| Growth | $200 | ~2,500 impressions |
| Scale | $500 | ~8,000 impressions |
Profile System
Editable Fields
- Username — unique, 3–20 chars, alphanumeric + underscores
- Display name — free text, 30 chars
- Avatar — upload (max 2MB, stored Supabase Storage)
- Bio — 160 chars
- Badge showcase — pick up to 5 from earned badges
- Privacy settings — who can see watch stats, friend list, guild
Public Profile Data
GET /api/users/:username/profile
Response:
{
username, displayName, avatarUrl, bio,
level, xp,
badges: Badge[5], // displayed badges
stats: {
watchHours: {
thisWeek: float,
thisMonth: float,
thisYear: float,
allTime: float
}
},
guild: { id, name, avatarUrl } | null,
joinedAt: ISO8601,
friendCount: int
}
Settings Tab
- Account — email, password, connected OAuth providers
- Privacy — profile visibility, searchability, who can friend you
- Notifications — friend requests, room invites, guild events, leaderboard
- Room defaults — default room visibility, mic preference
- Deactivate account — 30-day grace period, data retained
- Delete account — GDPR-compliant, full purge queued
Deployment Plan
Local Dev
Docker Compose for Postgres + Redis locally. pnpm dev starts all apps via Turborepo.
Staging
Auto-deployed on PR merge to develop. Vercel preview + Railway staging env.
Production
Deploy on merge to main. Zero-downtime via Railway rolling deploy + Vercel atomic deploys.
Mobile
EAS Build for TestFlight + Play internal track. EAS Update for JS-only OTA changes.
Environment Variables (key ones)
# API server DATABASE_URL=postgresql://... REDIS_URL=rediss://... JWT_SECRET=... SUPABASE_URL=... SUPABASE_SERVICE_KEY=... STRIPE_SECRET_KEY=... STRIPE_WEBHOOK_SECRET=... RESEND_API_KEY=... R2_BUCKET_URL=... R2_ACCESS_KEY=... # Web / Next.js (NEXT_PUBLIC_ prefix = client-safe) NEXT_PUBLIC_API_URL=https://api.yourdomain.com NEXT_PUBLIC_SUPABASE_URL=... NEXT_PUBLIC_SUPABASE_ANON_KEY=... NEXT_PUBLIC_AGORA_APP_ID=... # Mobile (via Expo EAS secrets) EXPO_PUBLIC_API_URL=https://api.yourdomain.com EXPO_PUBLIC_SUPABASE_URL=...
Development Roadmap
Phase 1 — Core (Weeks 1–6)
Monorepo setup, auth (OAuth), user profiles, basic room system (YouTube sync), real-time chat, friend system. Web + API only. Get the watch sync working perfectly.
Phase 2 — Gamification (Weeks 7–10)
XP system, levels, watch hour tracking, basic leaderboard. Google Drive + Spotify integration. Guild system. BullMQ jobs for background work.
Phase 3 — Events + Ads (Weeks 11–14)
Event system, badge collectables, sponsored events. Ad system with video delivery. Stripe integration for premium + ad purchases. Admin dashboard.
Phase 4 — Mobile + Desktop (Weeks 15–20)
React Native app (Expo), Tauri desktop build. Push notifications. Voice chat via Agora. Full feature parity with web.
Phase 5 — Polish + Launch (Weeks 21–24)
Performance optimization, security audit, analytics, onboarding flow, beta testing with 100 users, App Store + Play Store submission.