A high-performance multiplayer drawing and guessing game with real-time voice chat, AI-generated themed word packs, and a server-authoritative state machine โ powered by Redis-backed canvas history, LiveKit WebRTC audio, and strict runtime payload validation.
- Server-authoritative Finite State Machine (FSM) enforces valid game-state transitions and prevents illegal client actions.
- Redis Streams provide durable event sourcing for collaborative canvas reconstruction and reconnect recovery.
- Real-time voice chat via LiveKit โ server-issued JWT access tokens let players communicate over low-latency WebRTC audio without any media passing through the game server.
- AI-powered custom word generation โ a dedicated BullMQ worker uses Google Gemini 3.5 Flash to produce themed word packs (99 words per request) on demand, completely off the game-server hot path.
- Persistent session mapping allows players to reconnect within a 60-second grace window without losing game state or identity.
- Shared TypeScript contracts eliminate payload inconsistencies between the Next.js frontend and Express backend.
- Runtime Zod validation rejects malformed Socket.IO payloads before they reach business logic.
- 16ms batched canvas synchronization minimizes WebSocket overhead while preserving a smooth ~60fps real-time drawing experience.
- Fully responsive, mobile-first UI with single-panel navigation on mobile and a three-column grid on desktop, built with advanced Framer Motion animations.
- Instant dark/light theme toggling using the View Transitions API with radial clip-path animations and dynamic favicon swapping โ zero layout shift.
- Automatic host migration ensures multiplayer lobbies survive unexpected disconnects.
- Graceful shutdown routines clean up timers, BullMQ connections, Redis resources, and room state to prevent orphaned sessions.
Scribblitz is a Turborepo monorepo with three runtime services sharing a common @scribblitz/types contract, so the client and server can never silently drift out of sync on payload shapes. Real-time gameplay flows through Socket.IO, voice communication is offloaded to LiveKit, and background AI word generation is brokered through BullMQ over Redis.
graph TD
A[Browser] -->|HTTP + WebSocket| B["apps/web โ Next.js 16"]
A -->|WebSocket| C["apps/game-server โ Socket.IO + Express"]
A -->|WebRTC Audio| L["LiveKit Server (Cloud / Self-hosted)"]
C -->|Mint JWT Token| L
C --> D[("Redis โ canvas stream, pub/sub, BullMQ")]
C --> E[("PostgreSQL โ via Prisma")]
W["apps/worker โ BullMQ + Gemini 3.5 Flash"] -->|Consume ai-theme-queue| D
W -->|Return word list via job result| D
subgraph "Docker Compose network"
B
C
D
E
W
end
Clients never mutate game state directly โ every transition is validated and broadcast by the server's finite state machine (GameFSM.ts).
stateDiagram-v2
[*] --> LOBBY
LOBBY --> ROUND_STARTING : game:start (host, โฅ2 players)
ROUND_STARTING --> DRAWING : word selected (manual or 15s AFK timeout)
ROUND_STARTING --> ROUND_END : drawer disconnected during selection
DRAWING --> ROUND_END : all guessed correctly OR draw timer expired
ROUND_END --> ROUND_STARTING : intermission elapses, rounds remain
ROUND_END --> GAME_END : intermission elapses, final round
GAME_END --> LOBBY : host returns everyone to lobby
note right of DRAWING : PARALLEL_DRAWING state\nalso exists for future\nteam-battle mode
ArenaOrchestrator is the single top-level controller that routes the entire application UI based on the FSM game state. It subscribes to 20+ server Socket.IO events and dispatches state updates to the Zustand store.
graph LR
AO["ArenaOrchestrator (Master Router)"]
AO -->|"gameState === null"| HS["HomeScreen โ create / join room"]
AO -->|"gameState === LOBBY"| LS["LobbyScreen โ settings, players, voice"]
AO -->|"gameState !== null && !== LOBBY"| AG["Arena Game Grid"]
AG --> AH["ArenaHUD โ round counter, synced timer, word hint"]
AG --> AL["ArenaLeaderboard โ live scores, rank animations"]
AG --> AC["ArenaCanvas โ isolated drawing surface + toolbox"]
AG --> ACH["ArenaChat โ messages, guesses, ghost chat"]
AG --> VP["VoiceControls โ LiveKit mute / deafen / device select"]
AG --> MAM["MobileActionMenus โ radial arc menus (mobile only)"]
AG --> FE["FloatingEmotes โ animated emoji reactions"]
AG -.->|"overlays"| AW["WordSelectionOverlay โ 3 word choices (drawer)"]
AG -.->|"overlays"| RO["RoundEndOverlay โ scores, correct word"]
AG -.->|"overlays"| GO["GameOverModal โ podium, confetti, standings"]
AG -.->|"overlays"| GA["GameAbortModal โ insufficient players"]
style AO fill:#6366f1,color:#fff
style AC fill:#10b981,color:#fff
style AG fill:#1e293b,color:#fff
- Desktop (
lg:and above): all three main panels โ Leaderboard, Canvas, Chat โ render side-by-side in a CSS grid. - Mobile: a single panel is visible at a time. Radial arc action menus are anchored to the bottom corners (left: settings/navigation, right: voice controls).
ArenaCanvas isolation: All drawing state โ stroke buffer, current path, undo history, flood fill, canvas 2D context โ lives entirely inside the useCanvasDrawing hook. The ArenaCanvas component manages its own tool state (color, size, tool) as local React state, preventing palette changes from triggering re-renders in the orchestrator or sibling components. A standardized #FAFAF8 paper background ensures consistent artwork contrast across both light and dark application themes.
Rather than emitting a network event on every single pointer-move, the client buffers strokes locally and flushes the buffer to the server on a fixed setInterval (CANVAS_BATCH_INTERVAL_MS = 16, defined once in @scribblitz/shared so client and server always agree on the cadence). The server relays each batch to other players in the room and simultaneously appends it to a Redis Stream (room:<code>:canvas, soft-capped at ~5,000 entries with a 2-hour TTL), which is what makes canvas history replay possible for reconnecting or late-joining players.
sequenceDiagram
participant D as Drawer
participant S as game-server
participant R as Redis Stream
participant W as Watcher
loop pointer move
D->>D: draw locally, push to buffer
end
loop every 16ms (if buffer non-empty)
D->>S: canvas:batch { strokes[] }
S->>W: canvas:batch { strokes[] } (immediate relay)
S->>R: XADD room:<code>:canvas (async persist)
end
Note over W: reconnect / late join
W->>S: canvas:sync_request { roomCode }
S->>R: XRANGE room:<code>:canvas - +
R-->>S: full stream history
S->>W: canvas:history { strokes[] }
Voice chat is powered by LiveKit. The game server acts only as a token issuer โ all audio streams flow directly between the browser and the LiveKit server via WebRTC, keeping the game server's event loop free from media processing.
sequenceDiagram
participant C as Client (Browser)
participant GS as game-server
participant LK as LiveKit Server
Note over C: Player enters lobby or game
C->>GS: voice:token_request
GS->>GS: mintVoiceToken(userId, roomCode, username)
GS->>C: voice:token_issued { token, livekitUrl, roomName }
C->>LK: room.connect(livekitUrl, token) [WebRTC]
LK-->>C: Connected โ publish microphone track (muted by default)
Note over C,LK: Low-latency peer audio via WebRTC SFU
Note over C: Player leaves or game ends
C->>LK: room.disconnect()
- The client emits
voice:token_requestto the game server. - The server generates a LiveKit
AccessToken(grants:roomJoin,canPublish,canSubscribe; TTL: 10 minutes) and responds withvoice:token_issued { token, livekitUrl, roomName }. - The client connects to the LiveKit room with adaptive streaming, dynacast, echo cancellation, noise suppression, and auto gain control.
- Players can mute/unmute and deafen/undeafen (deafening automatically mutes the mic). A device selector supports audio input/output switching with Chrome device deduplication.
- On disconnect or game end, the client disconnects from the LiveKit room.
Custom themed word packs are generated off the main game-server thread using a BullMQ job queue and a dedicated worker process.
sequenceDiagram
participant H as Host (Browser)
participant GS as game-server
participant Q as Redis (BullMQ)
participant W as Worker (Gemini 3.5 Flash)
H->>GS: theme:generate { theme: "Pirates" }
GS->>GS: Rate limit check (80s cooldown per room)
GS->>Q: Add job to ai-theme-queue
Q->>W: Worker picks up job
W->>W: Gemini API โ 99 words, validate, filter, dedupe
W->>Q: Return result (โฅ60 valid words required)
Q->>GS: Job completed
GS->>H: room:config_updated + theme:generated_success
The worker enforces strict safety: prompt injection sanitization, content safety filters (BLOCK_LOW_AND_ABOVE), a hard blocklist for offensive terms, max 20 characters per word, max 2 words per phrase, and a minimum threshold of 60 valid words out of 99 requested.
-
Zero-Downtime Reconnects: Player identities are mapped to persistent UUIDs stored in
localStorage, decoupling gameplay sessions from volatile Socket.IO connection IDs. When a player disconnects, the server starts a 60-second reconnection window (broadcast viaplayer:disconnectedwithgracePeriodSeconds: 60) instead of immediately removing them. Upon reconnection, the server performs anXRANGEquery against the room's Redis Stream, allowing the client to replay the persisted canvas stroke history. If the disconnected player was the active drawer, the round is ended immediately to prevent game stall. -
16ms Batched Canvas Synchronization: Streaming every
mousemoveevent quickly overwhelms the network during rapid drawing. Instead, the client buffers strokes locally and flushes them every 16ms, while the server immediately relays batches to watchers viasocket.to()and asynchronously persists to Redis Streams. This dual-path architecture optimizes both latency (instant relay) and durability (persistent history). -
Dynamic Host Migration: If the lobby host disconnects or leaves unexpectedly, the game continues uninterrupted. The server automatically elects a random connected player as the new host and broadcasts a
room:host_changedevent. If remaining connected players fall belowMIN_PLAYERS(2), the game is aborted withgame:aborted { reason: 'insufficient_players' }. -
Single-Session Enforcement: If a player opens a duplicate browser tab, the server detects the duplicate
userId, sendsSESSION_EXPIREDto the old socket, and disconnects it โ preventing state corruption from concurrent connections. -
Real-Time Voice Chat (LiveKit): Players can communicate via low-latency WebRTC audio through a LiveKit SFU. The game server issues short-lived JWT access tokens (10-minute TTL) scoped to the room, while all media traffic flows directly between clients and the LiveKit server โ keeping the game server's event loop free from audio processing.
-
Contract-Driven Monorepo: Built as a Turborepo, the Next.js client and Express server share a single source of truth through the
@scribblitz/typesworkspace. Every Socket.IO event, finite state machine transition, and payload interface is imported from the same package, eliminating API contract drift between frontend and backend. -
Strict Runtime Validation: Compile-time TypeScript safety alone cannot protect against malformed network requests. Every incoming Socket.IO payload is validated at runtime using Zod schemas (
@scribblitz/validation) before reaching the core game logic. Invalid or malicious payloads are rejected immediately, ensuring consistent server-side data integrity. -
Role-Based Guards: Socket handlers enforce strict permission checks โ host-only actions (
room:update_config,game:start,game:return_to_lobby,theme:generate), drawer-only actions (word:select,canvas:batch,canvas:clear,canvas:undo), and state-dependent guards (e.g.,game:startrequiresLOBBYstate and no active AI generation). -
Data Sanitization: Non-host players receive sanitized room configs with
customWordListstripped and replaced bycustomWordCount. Room serialization scrubs sensitive server-side state:currentWord,wordChoices,usedWords, FSM instance, and all Node.js timer handles. -
AI Word Generation Off the Hot Path: Custom word requests are published to a BullMQ Redis queue (
ai-theme-queue) and consumed by a dedicated worker running Google Gemini 3.5 Flash. Results are delivered back via BullMQ job completion, with a 60-second timeout and 80-second per-room rate limit, ensuring the game server's event loop is never blocked by LLM inference.
-
VIP Ghost Chat: Once a player correctly guesses the word, their chat experience transitions into an isolated communication channel. The server routes their subsequent messages only to the Drawer and other successful guessers (
isGhost: true), creating a private conversation without revealing the answer to players who are still guessing. -
Forgiving Typo Engine: Player guesses are processed through a custom Levenshtein distance algorithm before evaluation. The threshold scales dynamically with word length (1 for short words, higher for longer words). If a guess falls within the threshold, the server suppresses the public chat message and privately emits a
guess:closeevent with"'<guess>' is very close!", encouraging the player without leaking information to the rest of the lobby. -
Progressive Hint System: To maintain engagement throughout each round, the server progressively reveals characters of the target word every
HINT_INTERVAL_SECONDS(10s). Maximum reveal is capped by difficulty: Easy (50%), Medium (30%), Hard (15%). Hint generation is entirely server-authoritative, ensuring every client receives synchronized updates throughword:hint_updatedevents while preventing client-side manipulation. -
Time-Decay Scoring: Correct guesses award points on a time-decay curve:
โ100 + 400 ร timeRatioโ, wheretimeRatiodecreases as the round progresses. The active drawer receives a 10% bonus of each guesser's score. This rewards both fast guessing and good drawing. -
Emote Reactions: Players can send real-time emoji reactions (๐ ๐ โค๏ธ ๐ฅ ๐ ๐ญ ๐คฏ) that float as animated overlays across the canvas area using Framer Motion spring physics with randomized horizontal sway and rotation. Rate-limited to 5 emotes per 3-second window per player.
-
Mobile-First Responsive Design: The
ArenaOrchestratordelivers a fully responsive experience: radial arc action menus anchored on mobile, and a three-column grid layout on desktop. The top header hides on mobile during active gameplay to maximize canvas real estate. Touch targets are sized for mobile interaction withsetPointerCapturefor reliable drawing. -
Advanced Framer Motion Animations:
- Radial arc action menus: tool and setting buttons spread using polar coordinate offsets with spring physics.
- Frosted-glass overlays: word selection, round-end, and game-end modals use
backdrop-blurwith semi-transparent backgrounds. - Podium animation: top 3 players get orchestrated spring-physics podium bars with confetti cannon for the winner.
- Leaderboard rank beams: zero-re-render synchronized metallic gradient sweeps via
useAnimationFramemutating a--beam-progressCSS custom property. - Theme toggle: View Transitions API with dynamic radial clip-path origin calculated from pointer coordinates and viewport dimensions.
| Technology | Version | Purpose |
|---|---|---|
| TypeScript | 5.9 |
End-to-end type safety and shared monorepo contracts |
| Next.js / React | 16.2 / 19.2 |
Frontend with App Router and React Compiler enabled |
| Tailwind CSS | v4 |
Utility-first styling with shared config package |
| Framer Motion | 12.x |
Radial menus, frosted-glass overlays, podium, floating emotes, layout fx |
| Zustand | 5.0 |
Client-side game state and toast notification management |
| next-themes | 0.4 |
Dark/light theme toggling via class strategy + View Transitions API |
| Express + Socket.IO | 5.x / 4.8 |
Real-time game server with rate-limited event handling |
| LiveKit | Client 2.13 / Server SDK 2.17 |
WebRTC voice chat โ token generation and SFU room management |
| BullMQ | 5.80 |
Redis-backed job queue for background AI word generation |
| ioredis | 5.11 |
Canvas stream buffering, BullMQ transport, connection state |
| Prisma + PostgreSQL | 7.8 / 16 |
Persistence layer (User and GameResult models) |
| Google Gemini | gemini-3.5-flash |
AI-powered themed word list generation via background worker |
| Pino + pino-roll | 10.3 / 4.0 |
Structured, rotating production logs (stdout + file) |
| Zod | 3.22+ |
Runtime payload validation on every socket event |
| canvas-confetti | โ | Winner celebration and easter egg effects |
| Docker | Compose v2 | Full-stack containerization with health checks and resource limits |
| Turborepo & pnpm | 2.9 / 11.5 |
Monorepo build orchestration and workspace management |
scribblitz/
โโโ apps/
โ โโโ web/ # Next.js frontend
โ โ โโโ src/
โ โ โโโ app/ # App Router: layout, home, game/[code] route
โ โ โโโ components/
โ โ โ โโโ Arena/ # ArenaOrchestrator (master router), ArenaCanvas,
โ โ โ โ # ArenaChat, ArenaLeaderboard, ArenaHUD,
โ โ โ โ # VoiceControls, MobileActionMenus, EmoteBar,
โ โ โ โ # FloatingEmotes, WordSelectionOverlay
โ โ โ โโโ Home/ # HomeScreen (create / join room, avatar picker)
โ โ โ โโโ Lobby/ # LobbyScreen, CustomWordsDrawer,
โ โ โ โ # StrictModeWarningOverlay
โ โ โ โโโ ui/ # Button, Modal, Input, ConfirmModal,
โ โ โ # GameOverModal, RoundEndOverlay,
โ โ โ # GameAbortModal, RulesModal, ToastManager
โ โ โโโ hooks/ # useGameSocket, useCanvasDrawing,
โ โ โ # useVoiceChat, useSyncedTimer
โ โ โโโ store/ # Zustand: gameStore, toastStore
โ โ โโโ utils/
โ โโโ game-server/ # Socket.IO + Express game server
โ โ โโโ src/
โ โ โโโ server.ts # Entry: auth middleware, connection routing,
โ โ โ # ghost sweeper, single-session enforcement,
โ โ โ # 60s disconnect grace, graceful shutdown
โ โ โโโ fsm/ # GameFSM state machine + roundManager
โ โ โโโ rooms/ # Room entity, RoomManager singleton
โ โ โโโ socket/handlers/ # lobby, game, message, canvas, voice, emote
โ โ โโโ socket/utils/ # getSocketByUserId, sanitizeConfig, serializeRoom
โ โ โโโ services/ # VoiceService (LiveKit), aiQueue (BullMQ)
โ โ โโโ rateLimiters/ # Theme generation rate limiter
โ โ โโโ words/ # Default word pool (~4,000 words)
โ โ โโโ lib/ # Redis, Prisma, Pino logger
โ โโโ worker/ # Background AI word generation service
โ โโโ src/
โ โโโ index.ts # BullMQ consumer โ Gemini 3.5 Flash โ
โ โ # validate, filter, dedupe โ return results
โ โโโ utils/logger.ts # Pino + pino-roll (daily rotation, 7 files)
โโโ packages/
โ โโโ types/ # Shared TS types, Socket.IO event constants,
โ โ # GameState enum, error codes, voice types
โ โโโ validation/ # Zod schemas: lobby, game, canvas, chat, emote, theme
โ โโโ shared/ # GAME_CONSTANTS (timers, limits, difficulty, AI config)
โ โโโ config/ # Shared Tailwind CSS config
โ โโโ eslint-config/ # Shared ESLint configs (base, next, react-internal)
โ โโโ typescript-config/ # Shared tsconfig presets (base, nextjs, react-library)
โโโ prisma/ # Schema + migrations (User, GameResult models)
โโโ docker-compose.yml # Local dev: Postgres + Redis
โโโ docker-compose.prod.yml # Production: full stack, resource limits, health checks
โโโ turbo.json # Build/dev/lint pipeline
Prerequisites: Node.js 24.16.0 (pinned in .nvmrc), pnpm 11.5.3, Docker.
# 1. Clone and install
git clone <repo-url> scribblitz
cd scribblitz
nvm use # Automatically switches to Node 24.16.0 based on .nvmrc
pnpm install
# 2. Set up environment variables
cp .env.example .envNote: The
.env.exampleships with production-oriented values. For local development, you must update your new.envfile to point at the Docker containers running on localhost. Match these exact values:
| Variable | Local Dev Value | Reason |
|---|---|---|
DATABASE_URL |
postgresql://scribblitz:scribblitz_dev_secret@localhost:5432/scribblitz_db?schema=public |
Points to the local Docker PostgreSQL instance using the development credentials defined in docker-compose.yml. |
POSTGRES_PASSWORD |
scribblitz_dev_secret |
Matches the password configured in docker-compose.yml. |
POSTGRES_USER |
scribblitz |
Matches the PostgreSQL user configured in docker-compose.yml. |
REDIS_URL |
redis://localhost:6379 |
Points to the local Docker Redis instance running without authentication. |
REDIS_PASSWORD |
(Leave empty or remove) | Development Redis runs without a password. |
NEXT_PUBLIC_GAME_SERVER_URL |
http://localhost:3001 |
Points to the local Express + Socket.IO game server. |
WEB_URL |
http://localhost:3000 |
Points to the local Next.js frontend application. |
GEMINI_API_KEY |
(Your Google Gemini API key) | Required for the AI word generation worker. Get one from Google AI Studio. |
LIVEKIT_URL |
wss://your-project.livekit.cloud |
LiveKit server URL. Use LiveKit Cloud or self-host. |
LIVEKIT_API_KEY |
(Your LiveKit API key) | Required for voice chat token generation. Obtain from your LiveKit dashboard. |
LIVEKIT_API_SECRET |
(Your LiveKit API secret) | Required for voice chat token generation. Obtain from your LiveKit dashboard. |
Optional services: Voice chat (
LIVEKIT_*) and AI word generation (GEMINI_API_KEY) are optional. If their environment variables are not set, those features will not affect the core drawing and guessing game it will continue to work without any issues.
# 3. Start local Postgres + Redis
docker compose up -d
# 4. Run database migrations
pnpm exec prisma migrate dev
# 5. Start everything (web + game-server + worker) with hot reload
pnpm devExpected Local Ports:
- Web:
http://localhost:3000 - Game server:
http://localhost:3001
The full stack can be deployed using Docker Compose. The production configuration includes health checks, resource limits, log volume mounts, and network segmentation (frontend/backend separation).
# Build and start all services
docker compose -f docker-compose.prod.yml up -d --build
# Or pull pre-built images from Docker Hub (if published)
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -dProduction services: web (port 3000), game-server (port 3001), worker, postgres, redis.
All event names are defined as constants in @scribblitz/types (ClientEvents / ServerEvents) and shared across the monorepo. Every client payload is validated against a Zod schema from @scribblitz/validation before reaching business logic.
Client โ Server Events
| Event | Constant | Payload | Zod Schema | Description |
|---|---|---|---|---|
room:create |
ROOM_CREATE |
{ username, avatarSeed, config? } |
createRoomSchema |
Create a new room (caller becomes host) |
room:join |
ROOM_JOIN |
{ roomCode, username, avatarSeed } |
joinRoomSchema |
Join an existing room (lobby state only) |
room:leave |
ROOM_LEAVE |
(none) | โ | Permanently leave the current room |
room:update_config |
ROOM_UPDATE_CONFIG |
Partial<RoomConfig> |
roomConfigSchema |
Host updates room settings (host only, lobby only) |
theme:generate |
GENERATE_THEME |
{ theme } |
generateThemeSchema |
Host requests AI-generated themed word pack (rate-limited) |
game:start |
GAME_START |
(none) | โ | Host starts the game (โฅ2 players, no active AI generation) |
word:select |
WORD_SELECT |
{ word } |
wordSelectSchema |
Drawer picks a word from the 3 choices |
game:return_to_lobby |
RETURN_TO_LOBBY |
(none) | โ | Host returns everyone to lobby after game end |
canvas:batch |
CANVAS_BATCH |
{ strokes: StrokeEvent[] } |
CanvasBatchSchema |
Buffered stroke batch (max 200 per batch, drawer only) |
canvas:clear |
CANVAS_CLEAR |
(none) | โ | Drawer clears the canvas |
canvas:undo |
CANVAS_UNDO |
(none) | โ | Drawer undoes the last stroke group |
canvas:sync_request |
CANVAS_SYNC_REQUEST |
{ roomCode } |
CanvasSyncRequestSchema |
Request full canvas history (rate-limited to 1/s) |
chat:message |
CHAT_MESSAGE |
{ message, roundId } |
chatMessageSchema |
Chat message or guess attempt (stale roundId silently dropped) |
emote:send |
EMOTE_SEND |
{ emoji } |
emoteSchema |
Send floating emoji reaction (rate-limited: 5 per 3s) |
voice:token_request |
VOICE_TOKEN_REQUEST |
(none) | โ | Request a LiveKit JWT access token for voice chat |
Server โ Client Events
| Event | Constant | Target | Payload | Description |
|---|---|---|---|---|
server:error |
ERROR |
Requesting socket | { code: ErrorCode, message, isFatal } |
Standardized error payload |
room:created |
ROOM_CREATED |
Host socket | { room: SerializedRoom } |
Confirms room creation with full room state |
room:joined |
ROOM_JOINED |
Joining socket | { room: SerializedRoom, serverNow? } |
Confirms join/reconnect with sanitized room state |
player:joined |
PLAYER_JOINED |
Room broadcast | { player: Player } |
New player entered or player reconnected |
player:left |
PLAYER_LEFT |
Room broadcast | { playerId, permanent: true } |
Player permanently left the room |
player:disconnected |
PLAYER_DISCONNECTED |
Room broadcast | { playerId, gracePeriodSeconds: 60 } |
Player disconnected, 60s reconnection window opened |
room:host_changed |
HOST_CHANGED |
Room broadcast | { newHostId } |
Host reassigned after disconnect/leave |
room:config_updated |
ROOM_CONFIG_UPDATED |
Room broadcast | { config: RoomConfig } |
Room settings changed (host gets full, others sanitized) |
theme:generated_success |
THEME_GENERATED_SUCCESS |
Host socket | {} |
AI word generation completed successfully |
game:state_changed |
GAME_STATE_CHANGED |
Room broadcast | { state: GameState } |
FSM transition broadcast |
round:starting |
ROUND_STARTING |
Room broadcast | { round, totalRounds, drawerId, roundId, timeRemainingMs } |
New round beginning, drawer chosen |
word:choices |
WORD_CHOICES |
Drawer only | { words: string[] } |
3 word options sent privately to drawer |
drawer:word_reveal |
DRAWER_WORD_REVEAL |
Drawer only | { word } |
Private reveal of the chosen word to drawer |
round:started |
ROUND_STARTED |
Room broadcast | { drawerId, wordLength, wordHint, timeRemainingMs } |
Drawing phase begins for all players |
word:hint_updated |
WORD_HINT_UPDATED |
Room broadcast | { hint } |
Progressive hint reveal (every 10s, capped by difficulty) |
canvas:batch |
CANVAS_BATCH |
Room (excl. drawer) | { strokes: StrokeEvent[] } |
Relayed stroke batch to watchers |
canvas:history |
CANVAS_HISTORY |
Requesting socket | { strokes: StrokeEvent[] } |
Full canvas history for sync |
canvas:cleared |
CANVAS_CLEARED |
Room broadcast | {} |
Canvas cleared by drawer |
canvas:undone |
CANVAS_UNDONE |
Room broadcast | { strokeId } |
Last stroke group removed (clients remove by strokeId) |
chat:broadcast |
CHAT_BROADCAST |
Room / VIP only | { senderId, senderName, message, isSystem, isGhost? } |
Public chat or VIP ghost chat message |
player:guessed |
PLAYER_GUESSED |
Room broadcast | { playerId, username } |
Public "guessed correctly" notice (word not revealed) |
guess:correct |
GUESS_CORRECT |
Guesser only | { word, pointsEarned } |
Private confirmation with word and points earned |
guess:close |
GUESS_CLOSE |
Guesser only | { message } |
Private "you're close!" hint |
score:update |
SCORE_UPDATE |
Room broadcast | { scores: Array<{ id, score }> } |
Updated scores after a correct guess |
round:end |
ROUND_END |
Room broadcast | { correctWord, reason, scores: Array<{id, username, score}>, isFinalRound, timeRemainingMs } |
Round concludes |
game:end |
GAME_END |
Room broadcast | { standings: Array<Player & { rank }> } |
Final ranked standings |
game:aborted |
GAME_ABORTED |
Room broadcast | { reason: 'insufficient_players' } |
Game ended early โ not enough players |
room:lobby_reset |
LOBBY_RESET |
Room broadcast | { room: SerializedRoom } |
Everyone returned to lobby (host full, others sanitized) |
emote:broadcast |
EMOTE_BROADCAST |
Room broadcast | { emoji, senderId, startX, id } |
Floating emoji reaction with random x-position |
voice:token_issued |
VOICE_TOKEN_ISSUED |
Requesting socket | { token, livekitUrl, roomName } |
LiveKit JWT access token and server URL |
Each stroke in a canvas:batch payload has the following structure, validated by StrokeEventSchema:
| Field | Type | Description |
|---|---|---|
type |
'draw' | 'erase' | 'fill' | 'clear' |
Stroke operation type |
x |
number (-1000โ5000) |
Current X coordinate (logical 800ร600 space) |
y |
number (-1000โ5000) |
Current Y coordinate |
lastX |
number (-1000โ5000) |
Previous X coordinate (for line interpolation) |
lastY |
number (-1000โ5000) |
Previous Y coordinate |
strokeId |
string (max 64) |
Groups related points for undo |
color |
string (max 30) |
Stroke color |
brushSize |
number (1โ100) |
Brush diameter |
sessionId |
string (max 16) |
Drawing session identifier |
timestamp |
number |
Client-side timestamp |
roundId |
number |
Round identifier for stale-data filtering |
V2 ships with voice chat, AI word generation, emote reactions, and a fully responsive mobile-first UI. Here's what's next:
- User Authentication & Profiles โ wiring up the existing Prisma
Usermodel with OAuth or magic-link authentication, enabling persistent player profiles, match history (GameResult), and lifetime stats. - Team Battle Mode โ the
GameState.PARALLEL_DRAWINGstate,teamIdplayer field, andteam-battleroom mode are already scaffolded in@scribblitz/types, ready for implementation with parallel drawing lanes and team scoring. - Horizontal Scaling with Redis Adapter โ replacing in-memory room state with the Socket.IO Redis adapter to enable multi-instance deployments behind a load balancer.
- In-memory game state โ all active rooms live in the game-server's process memory; a restart clears them. Redis is used for canvas history and BullMQ job queuing, not for cross-instance room state.
- No authentication โ player identity is a client-generated UUID in
localStorage. The PrismaUserandGameResultmodels exist but aren't wired to auth yet. - Single-instance architecture โ no horizontal scaling; one Node.js process handles all rooms.
Written by Mirza Mohammad Abbas โ LinkedIn
Scribblitz was born out of countless game nights playing Skribbl.io with my friends. While we loved the core drawing and guessing mechanics, we always wished for modern features like seamless built-in voice chat, mobile-first controls, and AI-generated custom word themes.
This project is both a tribute to those game nights and an engineering exercise in modernizing a classic web game into a high-performance, full-stack application.
This project is licensed under the MIT License - see the LICENSE file for details.