A real-time multiplayer drawing and guessing game inspired by Skribbl, built with the MERN stack and Socket.io. Players join rooms, take turns drawing a secret word, and compete on speed and accuracy. This README is a full developer reference: architecture, game flow, APIs, sockets, and customization.
Live : https://d-sketchrelay.vercel.app
- What the App Does
- Features
- Tech Stack
- Project Structure
- Architecture
- Game Flow
- Socket Events
- Word Bank + Hint Logic
- Security
- Local Setup
- Run Locally
- Changelog (recent)
d_SketchRelay is a competitive multiplayer drawing game with:
- Room creation/joining (public/private with code)
- Host controls (rounds, draw time, player limits, category, custom words)
- Real-time drawing broadcast via Socket.io
- Live text guessing with hint reveal logic
- Scoreboard and final ranking; stored in user profile
- Responsive UI for desktop/mobile
Players take turns drawing for defined drawTime, others guess via chat. Points depend on guess time and drawer gets bonus points for successful guessers. The game runs for rounds and terminates with winner summary + confetti.
- Multi-room, multi-player with host role and spectator mode
- Custom word categories (Animals, Food, Objects, Actions, Places, Movies, Characters)
- Drawer picks 1 of 3 word options, 15s auto-pick fallback
- Round-based gameplay, custom round and player limits
- In-game guessing and feedback + adaptive hint reveals
- HTML5 Canvas painting with mouse + touch support
- Brush sizes + palette colors + eraser + clear
- Replayable strokes via server sync
- Sound effects via Web Audio API (no files)
- Reaction emojis with animation
- Animated landing page + onboarding steps
- Lobby room list and filters + live updates
- Player list with status, host badge, spectator label
- Sticky top bar with status and guessed count
- Confetti + winner animation
- Mobile-first responsiveness
willReadFrequentlycontext for canvas snapshots (undo/redo)- CSS optimizations and dynamic min-heights
- Debounced network operations and room updates
- JWT auth with 7-day expiry
- Bcrypt password storage
- Validation in HTTP + sockets
- MongoDB persistence for profiles and scores
- Node.js (v18+)
- Express
- Socket.io
- MongoDB + Mongoose
- bcryptjs, jsonwebtoken, dotenv, cors
- React 18 + Vite
- React Router Dom
- Socket.io-client
- Axios
- CSS Modules
- Frontend stores JWT in
localStorage - Axios requests include
Authorization: Bearer <token> protectmiddleware on server verifies tokenAuthContextmanages current user state and refresh on load
- HTTP (
/api/auth,/api/rooms) for initial room fetch, create/join, user data. - Socket.io for live events: draw strokes, guesses, timing, scores, chat.
gameManagerkeeps room state in memory. Fields:- status (
waiting,choosing,playing,finished) - current draw word, word hint indices, round, score
- timer, guessed list, player list.
- status (
- Socket events are authoritative each step (
start-game,pick-word,draw, etc.) - If reconnecting, each client receives
game-state-syncfor full state.
- Host creates/join room.
- Players join via code.
- Player list updates in real-time.
- Host starts game when
activePlayers >= 2.
- Server picks 3 choices from category.
- Drawer receives
word-choices; others seechoosing-wordoverlay. - 15s countdown; auto-select once it hits 0.
new-turnbroadcast:- selected word hint (blanks)
- drawer name
- timer, round info
- Drawer draws with
brush/eraser; app emitsdrawevents. - Server re-broadcasts as
draw-broadcastto others.
- Guess submitted via
send-guess. - Correct answers:
correct-guesswith points. - Close guesses (edit distance 1-3) get in-game private feedback.
- Timed reveals at 65% and 85% draw time.
- Wrong but partially correct positions queue 1 extra reveal.
- Max 3 reveals per turn.
- Turn ends on timer 0 or all guessers guessed.
- Next turn OR
game-overwhen rounds complete. game-overshows final leaderboard, confetti, crown.- Server persists scores to MongoDB.
join-room{ roomCode, user }join-as-spectator{ roomCode, user }start-game{ roomCode }pick-word{ roomCode, word }draw{ roomCode, stroke }clear-canvas{ roomCode }send-guess{ roomCode, guess, userId }react{ roomCode, emoji }
player-joined,player-left,host-changedgame-started,choosing-word,word-choices,new-turn,your-worddraw-broadcast,canvas-clearedhint-update,timer-tickcorrect-guess,close-guess,turn-ended,game-overgame-state-sync,reaction,error
File: server/utils/words.js
WORD_CATEGORIESwith categories and word lists.- added easy, drawable terms + variety enough for long sessions.
WORDSflat list forallcategory.getRandomWord(category, customWords)with override.getThreeChoices(category, customWords)random unique set.getWordHint(word, revealedIndices)returns underscore hint with 3-space spacing on word breaks.checkGuess(guess, word)safe equal.editDistance(a,b)Levenshtein distance for "close guess" hint.getNextRevealIndex(word, revealedIndices)incremental reveal.
- Passwords hashed with bcrypt (12 salt rounds)
- JWT in Authorization header, 7d expiration
- All protected routes and socket events are validated
- Input validations on register, login, room creation, invites
git clone https://github.com/yourname/d_SketchRelay.gitcd d_SketchRelay- Setup
.envinserver/:PORT=5000MONGO_URI=...JWT_SECRET=...
cd server && npm installcd ../client && npm install
Terminal 1:
cd server
npm run devTerminal 2:
cd client
npm run dev- Create Web Service → root
server. - Build:
npm install, Start:node index.js. - Env vars:
MONGO_URI,JWT_SECRET,PORT.
- Create project -> root
client. - Env var:
VITE_API_URL=https://your-render-url. - Use
vercel.jsonrewrite to/index.html.
- include
http://localhost:5173,https://your-vercel-url.
- In-memory game state lost if server restarts in active room.
- Render free-tier cold-start delay (15 min inactivity)
- No persistent guessing chat across sessions (in-game only)
overscroll-behaviorflagged on old iOS browsers (compat warnings only)
- Fixed mobile canvas/chat overlap and made scroll stable.
- Added
willReadFrequentlyon canvas context. - Restored host start behavior, sync UI, and word list enrichment.
- Added robust README + professional docs.
- Fork 🔀
- Branch
feature/your-feature - Implement
- Commit with meaningful message
- PR + screenshots / short description
MIT
- Real-time multiplayer — every stroke, guess, and score update is instant via WebSocket
- 3 word choices — drawer picks one of 3 options before each turn; auto-selects if they don't choose in time
- Word categories — All, Animals, Food, Objects, Actions, Places, Movies
- Custom word lists — host can type their own comma-separated words; server uses them for the whole game
- Spectator mode — join a game already in progress and watch without playing
- Public & private rooms — public rooms appear in the lobby list; private rooms are join-by-code only but still visible in the list with a lock icon and disabled Join button
- Underscores represent unrevealed letters (
_ _ _ _ _ _ _ _) - Multi-word phrases show a clear gap between each word's underscores
- 2 timed reveals per turn: first at 65% elapsed, second at 85% elapsed
- Wrong guesses with correct-position letters silently queue a delayed reveal (one letter, after 5–12 seconds)
- Hard cap of 3 total revealed letters per turn
- "Close guess" private notifications with graduated intensity: close / very close / extremely close
- Full colour palette (14 colours)
- Variable brush size slider
- Clear canvas button
- Touch support — fully drawable on mobile
- Landing page with animated word preview, features section, how-it-works steps
- Persistent sticky navbar with Home and Lobby links
- Leave Room and Back to Home buttons everywhere
- Player avatars with deterministic colour-coded initials
- Live "X/Y guessed" counter in the top bar
- Reaction emojis (🔥 😂 👏 😮) with floating animations
- Winner crown drop animation + canvas confetti on game over
- Mute/unmute button — state persists in localStorage
- Programmatic sound effects via Web Audio API (no audio files)
- Animated lobby with live room list refresh every 5 seconds
- Skeleton loading placeholders
- Smooth fade-in page transitions
- Fully responsive — works on mobile, tablet, and desktop
- Passwords hashed with bcrypt (12 salt rounds)
- JWT authentication — 7-day expiry, verified on every protected request
- Server-side validation on every socket event (only drawer can draw, only host can start, etc.)
| Technology | Purpose |
|---|---|
| Node.js | JavaScript runtime — executes server-side JS outside the browser |
| Express.js | Web framework — handles HTTP routes, middleware, request/response |
| Socket.io | WebSocket library — real-time bidirectional communication |
| MongoDB Atlas | NoSQL cloud database — stores users, rooms, final scores |
| Mongoose | ODM — defines schemas, validates data, provides query methods |
| bcryptjs | Password hashing — never stores plain-text passwords |
| jsonwebtoken | JWT generation and verification — stateless authentication |
| dotenv | Loads environment variables from .env into process.env |
| cors | Allows cross-origin requests from the React frontend |
| Technology | Purpose |
|---|---|
| React 18 | UI library — component-based, reactive rendering |
| Vite | Build tool — fast dev server and production bundler |
| React Router DOM | Client-side routing — multiple pages without full page reloads |
| Axios | HTTP client — makes API calls to the Express backend |
| Socket.io-client | Browser-side WebSocket — connects to the Socket.io server |
| CSS Modules | Scoped styles — each component gets its own .module.css file |
sketchrelay/
│
├── server/
│ ├── config/
│ │ └── db.js # MongoDB connection
│ ├── controllers/
│ │ ├── authController.js # register, login, getMe
│ │ └── roomController.js # createRoom, joinRoom, getRoom, getAllRooms
│ ├── middleware/
│ │ └── authMiddleware.js # JWT verification — protect()
│ ├── models/
│ │ ├── User.js # User schema (username, email, password, scores)
│ │ └── Room.js # Room schema (code, host, players, settings, category)
│ ├── routes/
│ │ ├── authRoutes.js # /api/auth/*
│ │ └── roomRoutes.js # /api/rooms/*
│ ├── socket/
│ │ ├── socketHandler.js # All Socket.io event handlers
│ │ └── gameManager.js # In-memory game state, timers, hint logic, scoring
│ ├── utils/
│ │ └── words.js # Word bank (6 categories), hint generation, edit distance
│ ├── .env # Secrets — never commit
│ └── index.js # Entry point
│
└── client/
└── src/
├── api/
│ └── index.js # Axios instance + all HTTP call functions
├── components/
│ ├── Avatar.jsx # Colour-coded initials avatar
│ ├── Canvas.jsx # HTML5 drawing canvas (mouse + touch)
│ ├── Chat.jsx # Fixed-height scrollable message feed + input
│ ├── Confetti.jsx # Canvas confetti animation
│ ├── MuteButton.jsx # Toggle sound on/off
│ ├── Navbar.jsx # Sticky top navigation bar
│ ├── PlayerList.jsx # Player roster with drawer highlight and spectator badge
│ ├── Reactions.jsx # Emoji reaction buttons + floating animations
│ ├── Scoreboard.jsx # Live score ranking with avatars
│ ├── Timer.jsx # Countdown display (goes red at 10s)
│ └── WordChoiceScreen.jsx # 3-word choice overlay for the drawer
├── context/
│ ├── AuthContext.jsx # Global auth state (user, token, login, logout)
│ └── GameContext.jsx # Global game state (players, hints, scores, status)
├── hooks/
│ ├── useAuth.js # Shortcut to AuthContext
│ └── useSocket.js # Socket connection lifecycle + all event listeners
├── pages/
│ ├── Landing.jsx # Public landing page at /
│ ├── Login.jsx # Login form
│ ├── Register.jsx # Registration form
│ ├── Lobby.jsx # Create/join room, public room browser
│ └── GameRoom.jsx # Main game page (waiting / playing / finished views)
├── socket/
│ └── socket.js # Socket.io-client instance (autoConnect: false)
├── utils/
│ ├── avatar.js # Deterministic colour from username
│ └── sounds.js # Web Audio API sound manager
└── App.jsx # Routing + context providers
Browser (React + Socket.io-client)
│
│ HTTP (Axios) login, register, create/join room, load room metadata
│ WebSocket (Socket.io) drawing, guesses, scores, hints, reactions
▼
Express + Socket.io Server (Node.js on Render)
│
├── REST routes (/api/auth, /api/rooms)
│ └── Controllers → Mongoose → MongoDB Atlas
│
└── Socket.io event handlers
└── GameManager (in-memory Map)
└── MongoDB (persist final scores on game-over)
MongoDB (persistent) stores user accounts, room metadata (code, host, player list, settings), and final game scores. It survives server restarts.
In-memory GameManager stores everything that changes during an active game: the current word, the revealed hint indices, the countdown timer, who has guessed correctly, and live scores. It is a plain JavaScript Map — no database latency.
- User submits login form →
POST /api/auth/login - Server finds user, runs
bcrypt.compare()on the password - If correct, signs a JWT with
JWT_SECRET(7-day expiry) and returns it - Client stores the token in
localStorageasskribbl_token - Every Axios request injects
Authorization: Bearer <token>via an interceptor - The
protectmiddleware verifies the token and attachesreq.userbefore any protected route handler runs
When a player enters /room/:code, useSocket.js waits for the WebSocket handshake to complete, then emits join-room. The server adds the player to the Socket.io channel for that room code. From that point, io.to(roomCode).emit(...) reaches all connected players simultaneously without any manual tracking.
- Host creates a room in the Lobby → navigates to
/room/:code - Other players enter the same code →
join-roomsocket event → server adds them → broadcastsplayer-joinedwith the updated player list to everyone - All clients immediately see the updated list without any refresh
- If a player leaves,
player-leftfires with the updated list; if the host leaves, the next player becomes host viahost-changed - Host clicks Start game →
start-gameemitted → server validates (≥2 active players, status iswaiting) → sets status toplaying→ emitsgame-startedto all
- Server calls
prepareTurn()— picks 3 random words from the selected category - Emits
choosing-wordto everyone (so non-drawers see the animated waiting screen) - Emits
word-choicesprivately to the drawer's socket only — the 3 options - Drawer has 15 seconds to pick; if they don't, the first option is auto-selected
- Drawer clicks a word →
pick-wordemitted →beginTurn()called
- Canvas cleared for everyone before the new turn starts
new-turnbroadcast: drawer name, underscored hint, time left, round numberyour-wordsent privately to the drawer- Countdown timer starts;
timer-tickfires every second - Two timed letter reveals scheduled: one when 65% of time has elapsed, one at 85%
- Drawer moves mouse / touches screen →
drawevents → server broadcastsdraw-broadcastto all other players
- Player submits a guess →
send-guess→gameManager.processGuess() - Correct:
correct-guessbroadcast with points, drawer bonus, updated scores, guessed count - Wrong with correct-position letters: candidates queued; after 5–12s delay, one letter silently revealed for everyone via
hint-update(max 3 total reveals per turn) - Close (edit distance 1–3): private
close-guessto guesser only — "extremely close", "very close", or "close" - Wrong:
chat-messagebroadcast as plain text
- Timer reaches 0 or all guessers guess correctly →
endTurn() turn-endedbroadcast with the actual word and scores- Canvas cleared
- 4-second pause → next turn starts (or
game-overif all rounds complete)
game-overemitted with sorted final scores- MongoDB:
totalScoreandgamesPlayedincremented for each player - Players see the winner screen: crown animation, confetti, medal rankings
- "Back to lobby" resets game state and navigates to
/lobby
| Event | When | What happens |
|---|---|---|
| Timed reveal 1 | 65% of draw time elapsed | One random unrevealed letter shown |
| Timed reveal 2 | 85% of draw time elapsed | One more random unrevealed letter |
| Guess-triggered reveal | After a wrong guess with correct-position letters | One letter from the candidate pool shown after 5–12s delay |
| Hard cap | Any time | No more than 3 total letters revealed per turn |
Multi-word phrases (e.g. "time machine") display as two separate groups of underscores with a visible gap between them, so players know how many words to expect.
The drawer always sees the full word. Only guessers see underscores.
| Edit distance | Message shown |
|---|---|
| 1 | "[guess]" is extremely close! |
| 2 | "[guess]" is very close! |
| 3 | "[guess]" is close! |
points = floor(50 + (timeLeft / drawTime) × 450)
- Minimum: 50 points (guessed with 1 second left)
- Maximum: 500 points (guessed immediately)
- Drawer earns a bonus of 20% of each guesser's points for every correct guess during their turn
Final scores are saved to MongoDB after the game ends.
All routes require Content-Type: application/json. Protected routes require Authorization: Bearer <token>.
| Method | URL | Auth | Body | Returns |
|---|---|---|---|---|
| POST | /api/auth/register |
None | { username, email, password } |
{ token, user } |
| POST | /api/auth/login |
None | { email, password } |
{ token, user } |
| GET | /api/auth/me |
Required | — | { user } |
| Method | URL | Auth | Body | Returns |
|---|---|---|---|---|
| POST | /api/rooms |
Required | { maxPlayers?, rounds?, drawTime?, isPrivate?, category?, customWords? } |
{ room } |
| POST | /api/rooms/:code/join |
Required | — | { room } |
| GET | /api/rooms/public |
Required | — | { rooms[] } (all waiting rooms, public and private) |
| GET | /api/rooms/:code |
Required | — | { room } |
| Event | Payload | Description |
|---|---|---|
join-room |
{ roomCode, user: { id, username } } |
Subscribe to a room channel |
join-as-spectator |
{ roomCode, user: { id, username } } |
Join as a non-playing watcher |
start-game |
{ roomCode } |
Host starts the game |
pick-word |
{ roomCode, word } |
Drawer selects a word from the 3 choices |
draw |
{ roomCode, stroke: { x0,y0,x1,y1,color,size } } |
One line segment from the drawer |
clear-canvas |
{ roomCode } |
Drawer wipes the canvas |
send-guess |
{ roomCode, guess, userId } |
Player submits a guess |
react |
{ roomCode, emoji } |
Player sends an emoji reaction |
| Event | Payload | Who receives it |
|---|---|---|
player-joined |
{ players[], host, message } |
Everyone in room |
spectator-joined |
{ players[], host, message } |
Everyone in room |
player-left |
{ username, players[], host, isSpectator } |
Everyone remaining |
host-changed |
{ newHostId, newHostUsername, players[], host } |
Everyone remaining |
game-started |
{ players[], rounds, host } |
Everyone |
choosing-word |
{ drawer, choiceTime, round, maxRounds } |
Everyone |
word-choices |
{ choices[], choiceTime } |
Drawer only |
new-turn |
{ drawer, wordHint, timeLeft, round, maxRounds, guessedCount, totalGuessers } |
Everyone |
your-word |
{ word } |
Drawer only |
draw-broadcast |
{ x0,y0,x1,y1,color,size } |
Everyone except drawer |
canvas-cleared |
— | Everyone |
hint-update |
{ wordHint } |
Everyone |
timer-tick |
{ timeLeft } |
Everyone |
correct-guess |
{ username, points, drawerBonus, scores[], guessedCount, totalGuessers } |
Everyone |
chat-message |
{ username, message, type } |
Everyone |
close-guess |
{ message, level } |
Guesser only |
turn-ended |
{ word, scores[] } |
Everyone |
game-over |
{ finalScores[] } or { reason, finalScores[] } |
Everyone |
game-state-sync |
{ currentDrawer, wordHint, timeLeft, scores[], round, maxRounds, choosingWord } |
Reconnecting player only |
reaction |
{ username, emoji } |
Everyone |
error |
{ message } |
Sender only |
Create server/.env:
PORT=5000
MONGO_URI=mongodb+srv://<username>:<password>@cluster0.xxxxx.mongodb.net/sketchrelay?retryWrites=true&w=majority
JWT_SECRET=your_long_random_secret_string_hereNever commit this file. Add .env to your .gitignore.
Prerequisites: Node.js v18+, a MongoDB Atlas account (free tier)
# Clone the repository
git clone https://github.com/dangerSayan/d_sketchrelay.git
cd sketchrelay
# Install server dependencies
cd server
npm install
# Create .env file
cp .env.example .env
# Edit .env — add your MONGO_URI and JWT_SECRET
# Install client dependencies
cd ../client
npm installRun locally (two terminals):
# Terminal 1 — backend (auto-restarts on save)
cd server
npm run dev
# Terminal 2 — frontend (Vite dev server)
cd client
npm run devOpen http://localhost:5173 in your browser.
For any code change — bug fix, new feature, word list update, styling:
git add .
git commit -m "describe your change"
git pushRender redeploys the backend in ~60 seconds. Vercel redeploys the frontend in ~30 seconds. No dashboard action needed.
Exception: if you add a new environment variable, you must add it manually in the Render or Vercel dashboard and trigger a manual redeploy once.
- If the Render server restarts mid-game, in-memory game state (current word, timer, scores) is lost. Players would need to create a new room.
- The free Render tier has cold-start latency of 30–60 seconds after 15 minutes of inactivity.
- There is no persistent chat outside of guessing — all messages are guesses, system notifications, or game events.