VISHAL18for4 commited on
Commit
af0b3d5
·
verified ·
1 Parent(s): 4480626

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +7 -0
  2. README.md +88 -10
  3. package.json +21 -0
  4. server.js +659 -0
  5. words.js +71 -0
Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm install --production
5
+ COPY . .
6
+ EXPOSE 7860
7
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,10 +1,88 @@
1
- ---
2
- title: DRAWABLE
3
- emoji: 😻
4
- colorFrom: red
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎨 Drawable
2
+
3
+ > The ultimate Draw & Guess game — skribbl.io clone with superpowers.
4
+
5
+ ## 🚀 Features (beyond skribbl.io)
6
+
7
+ | Feature | Drawable | skribbl.io |
8
+ |---|---|---|
9
+ | Power-ups (Reveal, Time Warp, Bomb) | ✅ | ❌ |
10
+ | Emoji reactions (floating) | | ❌ |
11
+ | Drawing replay after each round | ✅ | ❌ |
12
+ | Live score graph timeline | ✅ | ❌ |
13
+ | Team mode (Red vs Blue) | ✅ | ❌ |
14
+ | Sudden Death (wrong = lose points) | ✅ | ❌ |
15
+ | Quick Draw mode (20s) | ✅ | ❌ |
16
+ | 10 word categories | ✅ | ❌ |
17
+ | Custom words | ✅ | ✅ |
18
+ | Achievements system | ✅ | ❌ |
19
+ | Streak bonuses | ✅ | ❌ |
20
+ | Host kick player | ✅ | ✅ |
21
+ | Spectator mode | ✅ | ❌ |
22
+ | Undo stroke | ✅ | ❌ |
23
+ | Shape tools (line, rect, circle) | ✅ | ❌ |
24
+ | Bucket fill tool | ✅ | ✅ |
25
+ | Close-guess detection | ✅ | ❌ |
26
+
27
+ ## 📁 File Structure
28
+
29
+ ```
30
+ drawable/
31
+ ├── server.js # Node.js + Socket.IO backend
32
+ ├── words.js # 300+ words across 10 categories
33
+ ├── package.json
34
+ ├── Dockerfile
35
+ ├── .dockerignore
36
+ └── public/
37
+ ├── index.html # Lobby
38
+ ├── game.html # Game screen
39
+ ├── css/style.css
40
+ └── js/
41
+ ├── lobby.js
42
+ └── game.js
43
+ ```
44
+
45
+ ## 🏠 Run Locally
46
+
47
+ ```bash
48
+ npm install
49
+ npm start
50
+ # Open http://localhost:7860
51
+ ```
52
+
53
+ ## 🤗 Deploy to Hugging Face Spaces
54
+
55
+ 1. Go to https://huggingface.co/new-space
56
+ 2. Name: `drawable` | SDK: **Docker** | Visibility: Public
57
+ 3. Clone the Space repo:
58
+ ```bash
59
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/drawable
60
+ ```
61
+ 4. Copy all project files into the cloned folder
62
+ 5. Push:
63
+ ```bash
64
+ git add . && git commit -m "init" && git push
65
+ ```
66
+ 6. Wait ~2 min for build. Your game is live! 🎉
67
+
68
+ > **Note:** Hugging Face Spaces expose port 7860 automatically — no config needed.
69
+
70
+ ## ❓ Do I need a database?
71
+
72
+ **No!** All game state is stored in-memory on the server.
73
+ - Rooms exist while players are connected
74
+ - Scores reset each game
75
+ - No user accounts needed — just pick a name and play
76
+
77
+ If you want persistent leaderboards later, add Supabase (free tier) and a `/leaderboard` endpoint.
78
+
79
+ ## 🎮 How Rooms Work
80
+
81
+ ```
82
+ Player → click "Quick Play"
83
+ Server: any open room? → YES → join it
84
+ → NO → create new room, wait for others
85
+
86
+ Player → click "Create Room" → get 6-char code → share with friends
87
+ Player → click "Join Room" → enter code → join instantly
88
+ ```
package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "drawable",
3
+ "version": "1.0.0",
4
+ "description": "Drawable - The Ultimate Draw & Guess Game",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js",
8
+ "dev": "nodemon server.js"
9
+ },
10
+ "dependencies": {
11
+ "express": "^4.18.2",
12
+ "socket.io": "^4.7.2",
13
+ "uuid": "^9.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "nodemon": "^3.0.2"
17
+ },
18
+ "engines": {
19
+ "node": ">=18.0.0"
20
+ }
21
+ }
server.js ADDED
@@ -0,0 +1,659 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const { Server } = require('socket.io');
4
+ const path = require('path');
5
+ const wordLists = require('./words');
6
+
7
+ const app = express();
8
+ const server = http.createServer(app);
9
+ const io = new Server(server, { cors: { origin: '*' } });
10
+ const PORT = process.env.PORT || 7860;
11
+
12
+ app.use(express.static(path.join(__dirname, 'public')));
13
+ app.get('/game', (req, res) => res.sendFile(path.join(__dirname, 'public', 'game.html')));
14
+ app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
15
+
16
+ // ─── In-memory game state ───────────────────────────────────────────────────
17
+ const rooms = {};
18
+
19
+ function genCode() {
20
+ let code;
21
+ do { code = Math.random().toString(36).substring(2, 8).toUpperCase(); } while (rooms[code]);
22
+ return code;
23
+ }
24
+
25
+ function getWords(settings, count = 3) {
26
+ let pool = [];
27
+ if (settings.wordMode === 'custom' && settings.customWords.length >= count) {
28
+ pool = [...settings.customWords];
29
+ } else if (settings.wordMode === 'mixed') {
30
+ const cats = settings.categories || Object.keys(wordLists);
31
+ cats.forEach(c => { if (wordLists[c]) pool = pool.concat(wordLists[c]); });
32
+ pool = pool.concat(settings.customWords || []);
33
+ } else {
34
+ const cats = settings.categories || Object.keys(wordLists);
35
+ cats.forEach(c => { if (wordLists[c]) pool = pool.concat(wordLists[c]); });
36
+ }
37
+ pool = [...new Set(pool)];
38
+ const choices = [];
39
+ while (choices.length < count && pool.length > 0) {
40
+ const i = Math.floor(Math.random() * pool.length);
41
+ choices.push(pool.splice(i, 1)[0]);
42
+ }
43
+ return choices;
44
+ }
45
+
46
+ function hintMask(word, revealed) {
47
+ return word.split('').map((c, i) => c === ' ' ? ' ' : revealed.includes(i) ? c : '_').join('');
48
+ }
49
+
50
+ function levenshtein(a, b) {
51
+ const dp = Array.from({ length: a.length + 1 }, (_, i) =>
52
+ Array.from({ length: b.length + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
53
+ for (let i = 1; i <= a.length; i++)
54
+ for (let j = 1; j <= b.length; j++)
55
+ dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
56
+ return dp[a.length][b.length];
57
+ }
58
+
59
+ function createRoom(settings = {}) {
60
+ const id = genCode();
61
+ rooms[id] = {
62
+ id,
63
+ host: null,
64
+ players: [],
65
+ spectators: [],
66
+ state: 'waiting',
67
+ settings: {
68
+ maxPlayers: 8,
69
+ rounds: 3,
70
+ drawTime: 80,
71
+ wordChoices: 3,
72
+ customWords: [],
73
+ wordMode: 'normal',
74
+ categories: Object.keys(wordLists),
75
+ teamMode: false,
76
+ suddenDeath: false,
77
+ quickDraw: false,
78
+ hintsEnabled: true,
79
+ ...settings
80
+ },
81
+ currentRound: 0,
82
+ drawerOrder: [],
83
+ currentDrawerIndex: 0,
84
+ currentWord: null,
85
+ wordChoices: [],
86
+ drawStrokes: [],
87
+ timer: null,
88
+ chooseTimer: null,
89
+ hintTimer1: null,
90
+ hintTimer2: null,
91
+ timeLeft: 0,
92
+ totalTime: 80,
93
+ hintsRevealed: [],
94
+ roundStartTime: null,
95
+ roundScores: [],
96
+ usedWords: [],
97
+ teams: { red: [], blue: [] },
98
+ scoreHistory: [], // [{round, scores:[{id,score}]}] for graph
99
+ };
100
+ return id;
101
+ }
102
+
103
+ function addPlayer(room, sid, name, avatar) {
104
+ const player = {
105
+ id: sid,
106
+ name: (name || 'Player').substring(0, 20),
107
+ score: 0,
108
+ avatar: avatar || { color: `hsl(${Math.floor(Math.random()*360)},70%,60%)`, icon: '😀' },
109
+ team: null,
110
+ streak: 0,
111
+ powerUps: { revealLetter: 1, timeWarp: 1, bomb: 1 },
112
+ achievements: [],
113
+ isDrawing: false,
114
+ hasGuessed: false,
115
+ guessTime: null,
116
+ isSpectator: false,
117
+ wordsDrawn: 0,
118
+ };
119
+ room.players.push(player);
120
+ return player;
121
+ }
122
+
123
+ function getPlayer(room, sid) {
124
+ return room.players.find(p => p.id === sid) || room.spectators.find(p => p.id === sid);
125
+ }
126
+
127
+ function clearAllTimers(room) {
128
+ if (room.timer) { clearInterval(room.timer); room.timer = null; }
129
+ if (room.chooseTimer) { clearTimeout(room.chooseTimer); room.chooseTimer = null; }
130
+ if (room.hintTimer1) { clearTimeout(room.hintTimer1); room.hintTimer1 = null; }
131
+ if (room.hintTimer2) { clearTimeout(room.hintTimer2); room.hintTimer2 = null; }
132
+ }
133
+
134
+ function shuffle(arr) {
135
+ for (let i = arr.length - 1; i > 0; i--) {
136
+ const j = Math.floor(Math.random() * (i + 1));
137
+ [arr[i], arr[j]] = [arr[j], arr[i]];
138
+ }
139
+ return arr;
140
+ }
141
+
142
+ function getRoomData(room) {
143
+ return {
144
+ id: room.id,
145
+ host: room.host,
146
+ state: room.state,
147
+ settings: room.settings,
148
+ players: room.players,
149
+ spectators: room.spectators,
150
+ currentRound: room.currentRound,
151
+ totalRounds: room.settings.rounds,
152
+ teams: room.teams,
153
+ };
154
+ }
155
+
156
+ function broadcastPlayers(room) {
157
+ io.to(room.id).emit('playerUpdate', { players: room.players });
158
+ }
159
+
160
+ // ─── Game Flow ─────────────────────────────────────────────────────��─────────
161
+ function startGame(room) {
162
+ room.state = 'playing';
163
+ room.currentRound = 1;
164
+ room.scoreHistory = [];
165
+ room.usedWords = [];
166
+ room.players.forEach(p => {
167
+ p.score = 0; p.streak = 0; p.achievements = [];
168
+ p.powerUps = { revealLetter: 1, timeWarp: 1, bomb: 1 };
169
+ });
170
+
171
+ if (room.settings.teamMode) assignTeams(room);
172
+
173
+ room.drawerOrder = shuffle(room.players.map(p => p.id));
174
+ room.currentDrawerIndex = 0;
175
+ startRound(room);
176
+ }
177
+
178
+ function assignTeams(room) {
179
+ room.teams = { red: [], blue: [] };
180
+ room.players.forEach((p, i) => {
181
+ p.team = i % 2 === 0 ? 'red' : 'blue';
182
+ room.teams[p.team].push(p.id);
183
+ });
184
+ }
185
+
186
+ function startRound(room) {
187
+ clearAllTimers(room);
188
+ room.players.forEach(p => { p.hasGuessed = false; p.isDrawing = false; p.guessTime = null; });
189
+ room.drawStrokes = [];
190
+ room.hintsRevealed = [];
191
+ room.roundScores = [];
192
+ room.currentWord = null;
193
+
194
+ // Find drawer, skip if missing
195
+ let drawerFound = false;
196
+ while (!drawerFound && room.currentDrawerIndex < room.drawerOrder.length) {
197
+ const drawerId = room.drawerOrder[room.currentDrawerIndex];
198
+ const drawer = room.players.find(p => p.id === drawerId);
199
+ if (drawer) { drawer.isDrawing = true; drawerFound = true; }
200
+ else room.currentDrawerIndex++;
201
+ }
202
+
203
+ if (!drawerFound) { nextTurn(room); return; }
204
+
205
+ const drawer = room.players.find(p => p.isDrawing);
206
+ const choices = getWords(room.settings, room.settings.wordChoices);
207
+ room.wordChoices = choices;
208
+ room.state = 'choosing';
209
+
210
+ io.to(room.id).emit('roundStart', {
211
+ round: room.currentRound,
212
+ totalRounds: room.settings.rounds,
213
+ drawer: { id: drawer.id, name: drawer.name, avatar: drawer.avatar },
214
+ players: room.players,
215
+ });
216
+
217
+ io.to(drawer.id).emit('chooseWord', { choices });
218
+
219
+ room.chooseTimer = setTimeout(() => {
220
+ if (room.state === 'choosing') {
221
+ wordChosen(room, choices[Math.floor(Math.random() * choices.length)]);
222
+ }
223
+ }, 15000);
224
+ }
225
+
226
+ function wordChosen(room, word) {
227
+ clearAllTimers(room);
228
+ room.currentWord = word;
229
+ room.usedWords.push(word);
230
+ room.state = 'drawing';
231
+
232
+ const drawTime = room.settings.quickDraw ? 20 : room.settings.drawTime;
233
+ room.timeLeft = drawTime;
234
+ room.totalTime = drawTime;
235
+ room.roundStartTime = Date.now();
236
+
237
+ const drawer = room.players.find(p => p.isDrawing);
238
+ const mask = hintMask(word, []);
239
+ const lengths = word.split(' ').map(w => w.length);
240
+
241
+ io.to(drawer.id).emit('wordChosen', { word, hint: mask, lengths, isDrawer: true });
242
+ socket_to_room_except(room.id, drawer.id, 'wordChosen', { word: null, hint: mask, lengths, isDrawer: false });
243
+
244
+ // Auto hints
245
+ if (room.settings.hintsEnabled) {
246
+ room.hintTimer1 = setTimeout(() => { if (room.state === 'drawing') revealHint(room); }, drawTime * 500);
247
+ room.hintTimer2 = setTimeout(() => { if (room.state === 'drawing') revealHint(room); }, drawTime * 750);
248
+ }
249
+
250
+ // Countdown
251
+ room.timer = setInterval(() => {
252
+ room.timeLeft--;
253
+ io.to(room.id).emit('timerUpdate', { timeLeft: room.timeLeft, totalTime: room.totalTime });
254
+ if (room.timeLeft <= 0) endRound(room);
255
+ }, 1000);
256
+ }
257
+
258
+ function socket_to_room_except(roomId, exceptId, event, data) {
259
+ io.to(roomId).except(exceptId).emit(event, data);
260
+ }
261
+
262
+ function revealHint(room) {
263
+ const word = room.currentWord;
264
+ if (!word) return;
265
+ const unrevealed = [];
266
+ word.split('').forEach((c, i) => { if (c !== ' ' && !room.hintsRevealed.includes(i)) unrevealed.push(i); });
267
+ if (unrevealed.length === 0) return;
268
+ const n = Math.max(1, Math.floor(unrevealed.length * 0.3));
269
+ const picks = shuffle([...unrevealed]).slice(0, n);
270
+ room.hintsRevealed.push(...picks);
271
+ const drawer = room.players.find(p => p.isDrawing);
272
+ const hint = hintMask(word, room.hintsRevealed);
273
+ socket_to_room_except(room.id, drawer?.id || '', 'hintUpdate', { hint, revealed: room.hintsRevealed });
274
+ }
275
+
276
+ function checkGuess(room, sid, text) {
277
+ if (!room.currentWord || room.state !== 'drawing') return 'ignore';
278
+ const player = room.players.find(p => p.id === sid);
279
+ if (!player || player.hasGuessed || player.isDrawing) return 'ignore';
280
+
281
+ const guess = text.trim().toLowerCase();
282
+ const answer = room.currentWord.toLowerCase();
283
+
284
+ if (guess === answer) {
285
+ const elapsed = (Date.now() - room.roundStartTime) / 1000;
286
+ const timeRatio = Math.max(0, 1 - elapsed / room.totalTime);
287
+ const baseScore = 200;
288
+ const timeBonus = Math.round(timeRatio * 300);
289
+ const firstBonus = room.roundScores.length === 0 ? 50 : 0;
290
+ const streakBonus = player.streak * 25;
291
+ const total = baseScore + timeBonus + firstBonus + streakBonus;
292
+
293
+ player.hasGuessed = true;
294
+ player.guessTime = elapsed;
295
+ player.score += total;
296
+ player.streak++;
297
+ player.wordsDrawn;
298
+
299
+ const drawer = room.players.find(p => p.isDrawing);
300
+ if (drawer) {
301
+ const drawerGain = Math.round(50 + (room.roundScores.length === 0 ? 0 : 10));
302
+ drawer.score += drawerGain;
303
+ }
304
+
305
+ room.roundScores.push({ id: sid, score: total, time: elapsed });
306
+ checkAchievements(room, player);
307
+
308
+ const nonDrawers = room.players.filter(p => !p.isDrawing && !p.isSpectator);
309
+ if (nonDrawers.every(p => p.hasGuessed)) setTimeout(() => endRound(room), 1500);
310
+
311
+ return { correct: true, score: total, streak: player.streak, firstBonus };
312
+ }
313
+
314
+ const dist = levenshtein(guess, answer);
315
+ if (dist <= 2 && guess.length > 2) return 'close';
316
+
317
+ if (room.settings.suddenDeath) {
318
+ player.score = Math.max(0, player.score - 15);
319
+ broadcastPlayers(room);
320
+ }
321
+ return 'wrong';
322
+ }
323
+
324
+ function checkAchievements(room, player) {
325
+ const unlocked = [];
326
+ const add = (id, name, desc) => {
327
+ if (!player.achievements.includes(id)) {
328
+ player.achievements.push(id);
329
+ unlocked.push({ id, name, desc });
330
+ }
331
+ };
332
+ if (player.guessTime < 8) add('speed_demon', '⚡ Speed Demon', 'Guessed in under 8 seconds!');
333
+ if (player.streak >= 3) add('on_fire', '🔥 On Fire', '3 correct guesses in a row!');
334
+ if (player.streak >= 5) add('unstoppable', '💎 Unstoppable', '5 in a row!');
335
+ if (room.roundScores.length === 1) add('first_blood', '🩸 First Blood', 'First to guess the word!');
336
+ if (room.hintsRevealed.length === 0 && player.guessTime < room.totalTime * 0.5)
337
+ add('no_hints', '🕵️ Sherlock', 'Guessed with zero hints revealed!');
338
+ if (unlocked.length > 0) io.to(player.id).emit('achievementUnlocked', unlocked);
339
+ }
340
+
341
+ function endRound(room) {
342
+ clearAllTimers(room);
343
+ if (room.state === 'intermission' || room.state === 'ended') return;
344
+ room.state = 'intermission';
345
+
346
+ const drawer = room.players.find(p => p.isDrawing);
347
+
348
+ // Picasso achievement
349
+ const nonDrawers = room.players.filter(p => !p.isDrawing && !p.isSpectator);
350
+ if (drawer && nonDrawers.length > 0 && nonDrawers.every(p => p.hasGuessed)) {
351
+ if (!drawer.achievements.includes('picasso')) {
352
+ drawer.achievements.push('picasso');
353
+ io.to(drawer.id).emit('achievementUnlocked', [{ id: 'picasso', name: '🎨 Picasso', desc: 'Everyone guessed your drawing!' }]);
354
+ }
355
+ drawer.score += 100; // bonus
356
+ }
357
+
358
+ // Record score history
359
+ room.scoreHistory.push({
360
+ round: room.currentRound,
361
+ drawerTurn: room.currentDrawerIndex,
362
+ scores: room.players.map(p => ({ id: p.id, name: p.name, score: p.score }))
363
+ });
364
+
365
+ io.to(room.id).emit('roundEnd', {
366
+ word: room.currentWord,
367
+ scores: room.players.map(p => ({
368
+ id: p.id, name: p.name, score: p.score, avatar: p.avatar,
369
+ roundGain: room.roundScores.find(r => r.id === p.id)?.score || 0,
370
+ guessTime: p.guessTime,
371
+ })),
372
+ drawStrokes: room.drawStrokes,
373
+ drawer: drawer ? { id: drawer.id, name: drawer.name } : null,
374
+ scoreHistory: room.scoreHistory,
375
+ });
376
+
377
+ room.timer = setTimeout(() => {
378
+ if (!rooms[room.id]) return;
379
+ nextTurn(room);
380
+ }, 7000);
381
+ }
382
+
383
+ function nextTurn(room) {
384
+ clearAllTimers(room);
385
+ room.currentDrawerIndex++;
386
+
387
+ if (room.currentDrawerIndex >= room.drawerOrder.length) {
388
+ room.currentRound++;
389
+ if (room.currentRound > room.settings.rounds) { endGame(room); return; }
390
+ room.drawerOrder = shuffle(room.players.map(p => p.id));
391
+ room.currentDrawerIndex = 0;
392
+ }
393
+
394
+ if (room.players.length < 2) {
395
+ room.state = 'waiting';
396
+ io.to(room.id).emit('gameAborted', { reason: 'Not enough players' });
397
+ return;
398
+ }
399
+ startRound(room);
400
+ }
401
+
402
+ function endGame(room) {
403
+ clearAllTimers(room);
404
+ room.state = 'ended';
405
+
406
+ const sorted = [...room.players].sort((a, b) => b.score - a.score);
407
+
408
+ // Victory achievement
409
+ if (sorted[0]) {
410
+ const winner = sorted[0];
411
+ if (!winner.achievements.includes('champion')) {
412
+ winner.achievements.push('champion');
413
+ io.to(winner.id).emit('achievementUnlocked', [{ id: 'champion', name: '👑 Champion', desc: 'You won the game!' }]);
414
+ }
415
+ }
416
+
417
+ io.to(room.id).emit('gameEnd', {
418
+ players: sorted.map((p, i) => ({
419
+ id: p.id, name: p.name, score: p.score,
420
+ rank: i + 1, achievements: p.achievements, avatar: p.avatar, streak: p.streak
421
+ })),
422
+ scoreHistory: room.scoreHistory,
423
+ });
424
+
425
+ setTimeout(() => { if (rooms[room.id]) delete rooms[room.id]; }, 60000);
426
+ }
427
+
428
+ // ─── Socket.IO ───────────────────────────────────────────────────────────────
429
+ io.on('connection', (socket) => {
430
+
431
+ socket.on('createRoom', ({ name, avatar, settings }) => {
432
+ const id = createRoom(settings || {});
433
+ const room = rooms[id];
434
+ room.host = socket.id;
435
+ const player = addPlayer(room, socket.id, name, avatar);
436
+ socket.join(id);
437
+ socket.roomId = id;
438
+ socket.emit('roomCreated', { roomId: id, player, room: getRoomData(room) });
439
+ });
440
+
441
+ socket.on('joinRoom', ({ roomId, name, avatar, asSpectator }) => {
442
+ const code = (roomId || '').toUpperCase().trim();
443
+ const room = rooms[code];
444
+ if (!room) { socket.emit('joinError', { message: 'Room not found. Check the code!' }); return; }
445
+ if (!asSpectator && room.players.length >= room.settings.maxPlayers && room.state === 'waiting') {
446
+ socket.emit('joinError', { message: 'Room is full!' }); return;
447
+ }
448
+
449
+ socket.join(code);
450
+ socket.roomId = code;
451
+
452
+ let player;
453
+ if (asSpectator || room.state !== 'waiting') {
454
+ player = addPlayer(room, socket.id, name, avatar);
455
+ player.isSpectator = true;
456
+ room.players = room.players.filter(p => p.id !== socket.id); // remove from players
457
+ room.spectators.push(player);
458
+ } else {
459
+ player = addPlayer(room, socket.id, name, avatar);
460
+ }
461
+
462
+ socket.emit('roomJoined', { player, room: getRoomData(room) });
463
+ socket.to(code).emit('playerJoined', { player, players: room.players, spectators: room.spectators });
464
+ });
465
+
466
+ socket.on('joinRandom', ({ name, avatar }) => {
467
+ const available = Object.values(rooms).find(r =>
468
+ r.state === 'waiting' &&
469
+ r.players.length > 0 &&
470
+ r.players.length < r.settings.maxPlayers
471
+ );
472
+ if (available) {
473
+ socket.emit('joinRoom', {}); // trigger joinRoom flow with found room
474
+ const player = addPlayer(available, socket.id, name, avatar);
475
+ socket.join(available.id);
476
+ socket.roomId = available.id;
477
+ socket.emit('roomJoined', { player, room: getRoomData(available) });
478
+ socket.to(available.id).emit('playerJoined', { player, players: available.players });
479
+ } else {
480
+ const id = createRoom();
481
+ const room = rooms[id];
482
+ room.host = socket.id;
483
+ const player = addPlayer(room, socket.id, name, avatar);
484
+ socket.join(id);
485
+ socket.roomId = id;
486
+ socket.emit('roomCreated', { roomId: id, player, room: getRoomData(room) });
487
+ }
488
+ });
489
+
490
+ socket.on('startGame', () => {
491
+ const room = rooms[socket.roomId];
492
+ if (!room) return;
493
+ if (room.host !== socket.id) { socket.emit('gameError', { message: 'Only the host can start' }); return; }
494
+ if (room.players.length < 2) { socket.emit('gameError', { message: 'Need at least 2 players' }); return; }
495
+ startGame(room);
496
+ });
497
+
498
+ socket.on('chooseWord', ({ word }) => {
499
+ const room = rooms[socket.roomId];
500
+ if (!room || room.state !== 'choosing') return;
501
+ const player = room.players.find(p => p.id === socket.id);
502
+ if (!player?.isDrawing || !room.wordChoices.includes(word)) return;
503
+ wordChosen(room, word);
504
+ });
505
+
506
+ socket.on('draw', (data) => {
507
+ const room = rooms[socket.roomId];
508
+ if (!room || room.state !== 'drawing') return;
509
+ const player = room.players.find(p => p.id === socket.id);
510
+ if (!player?.isDrawing) return;
511
+ const stamped = { ...data, t: Date.now() - (room.roundStartTime || Date.now()) };
512
+ room.drawStrokes.push(stamped);
513
+ socket.to(socket.roomId).emit('draw', data);
514
+ });
515
+
516
+ socket.on('clearCanvas', () => {
517
+ const room = rooms[socket.roomId];
518
+ if (!room || room.state !== 'drawing') return;
519
+ const player = room.players.find(p => p.id === socket.id);
520
+ if (!player?.isDrawing) return;
521
+ room.drawStrokes = [{ type: 'clear', t: 0 }];
522
+ io.to(socket.roomId).emit('clearCanvas');
523
+ });
524
+
525
+ socket.on('guess', ({ text }) => {
526
+ const room = rooms[socket.roomId];
527
+ if (!room || !text) return;
528
+ const player = getPlayer(room, socket.id);
529
+ if (!player) return;
530
+
531
+ // Block drawer from revealing word in chat
532
+ if (player.isDrawing) {
533
+ socket.emit('chatMessage', { system: true, text: '🤫 You cannot chat while drawing!', type: 'warn' });
534
+ return;
535
+ }
536
+
537
+ const result = checkGuess(room, socket.id, text);
538
+
539
+ if (result === 'ignore') return;
540
+
541
+ if (result?.correct) {
542
+ socket.emit('correctGuess', { score: result.score, streak: result.streak, firstBonus: result.firstBonus });
543
+ io.to(socket.roomId).emit('chatMessage', {
544
+ system: true,
545
+ text: `🎉 ${player.name} guessed it! (+${result.score}pts${result.streak > 1 ? ` 🔥x${result.streak}` : ''})`,
546
+ type: 'correct'
547
+ });
548
+ broadcastPlayers(room);
549
+ } else if (result === 'close') {
550
+ socket.emit('chatMessage', { system: true, text: `🌡️ So close! Keep trying...`, type: 'close' });
551
+ io.to(socket.roomId).emit('chatMessage', { sender: player.name, text: '...', playerId: socket.id, type: 'guess' });
552
+ } else {
553
+ io.to(socket.roomId).emit('chatMessage', { sender: player.name, text, playerId: socket.id, type: 'guess' });
554
+ }
555
+ });
556
+
557
+ socket.on('chatMessage', ({ text }) => {
558
+ const room = rooms[socket.roomId];
559
+ if (!room || !text) return;
560
+ const player = getPlayer(room, socket.id);
561
+ if (!player) return;
562
+ // Only allow chat in non-drawing state or for spectators
563
+ if (room.state === 'drawing' && !player.isSpectator) {
564
+ // This goes through guess flow - ignore raw chat
565
+ return;
566
+ }
567
+ io.to(socket.roomId).emit('chatMessage', { sender: player.name, text: text.substring(0, 120), playerId: socket.id, type: 'chat' });
568
+ });
569
+
570
+ socket.on('usePowerUp', ({ type }) => {
571
+ const room = rooms[socket.roomId];
572
+ if (!room || room.state !== 'drawing') return;
573
+ const player = room.players.find(p => p.id === socket.id);
574
+ if (!player || player.isDrawing || !player.powerUps[type] || player.powerUps[type] <= 0) return;
575
+
576
+ player.powerUps[type]--;
577
+
578
+ if (type === 'revealLetter') {
579
+ revealHint(room);
580
+ io.to(socket.roomId).emit('chatMessage', { system: true, text: `🔍 ${player.name} revealed a letter!`, type: 'powerup' });
581
+ } else if (type === 'timeWarp') {
582
+ room.timeLeft = Math.min(room.timeLeft + 15, room.totalTime);
583
+ io.to(socket.roomId).emit('timerUpdate', { timeLeft: room.timeLeft, totalTime: room.totalTime });
584
+ io.to(socket.roomId).emit('chatMessage', { system: true, text: `⏰ ${player.name} warped time! +15s`, type: 'powerup' });
585
+ } else if (type === 'bomb') {
586
+ room.drawStrokes = [{ type: 'clear', t: Date.now() - room.roundStartTime }];
587
+ io.to(socket.roomId).emit('clearCanvas');
588
+ io.to(socket.roomId).emit('chatMessage', { system: true, text: `💣 ${player.name} dropped a BOMB! Canvas cleared!`, type: 'powerup' });
589
+ }
590
+
591
+ socket.emit('powerUpUsed', { type, remaining: player.powerUps });
592
+ });
593
+
594
+ socket.on('reaction', ({ emoji }) => {
595
+ const room = rooms[socket.roomId];
596
+ if (!room) return;
597
+ const player = getPlayer(room, socket.id);
598
+ io.to(socket.roomId).emit('reaction', { emoji, name: player?.name || 'Someone' });
599
+ });
600
+
601
+ socket.on('updateSettings', (settings) => {
602
+ const room = rooms[socket.roomId];
603
+ if (!room || room.host !== socket.id || room.state !== 'waiting') return;
604
+ Object.assign(room.settings, settings);
605
+ io.to(socket.roomId).emit('settingsUpdated', room.settings);
606
+ });
607
+
608
+ socket.on('kickPlayer', ({ playerId }) => {
609
+ const room = rooms[socket.roomId];
610
+ if (!room || room.host !== socket.id) return;
611
+ const target = io.sockets.sockets.get(playerId);
612
+ if (target) {
613
+ target.emit('kicked', { reason: 'You were kicked by the host' });
614
+ target.leave(socket.roomId);
615
+ }
616
+ room.players = room.players.filter(p => p.id !== playerId);
617
+ io.to(socket.roomId).emit('playerLeft', { playerId, players: room.players });
618
+ });
619
+
620
+ socket.on('requestReplay', () => {
621
+ // Allow replay of last round strokes - sent on roundEnd already
622
+ });
623
+
624
+ socket.on('disconnect', () => {
625
+ const room = rooms[socket.roomId];
626
+ if (!room) return;
627
+
628
+ const wasDrawing = room.players.find(p => p.id === socket.id)?.isDrawing;
629
+ room.players = room.players.filter(p => p.id !== socket.id);
630
+ room.spectators = room.spectators.filter(p => p.id !== socket.id);
631
+
632
+ if (room.players.length === 0 && room.spectators.length === 0) {
633
+ clearAllTimers(room);
634
+ delete rooms[socket.roomId];
635
+ return;
636
+ }
637
+
638
+ if (room.host === socket.id && room.players.length > 0) {
639
+ room.host = room.players[0].id;
640
+ io.to(room.host).emit('youAreHost');
641
+ }
642
+
643
+ io.to(socket.roomId).emit('playerLeft', { playerId: socket.id, players: room.players });
644
+
645
+ if (wasDrawing && (room.state === 'drawing' || room.state === 'choosing')) {
646
+ endRound(room);
647
+ }
648
+
649
+ if (room.players.length < 2 && room.state !== 'waiting' && room.state !== 'ended') {
650
+ clearAllTimers(room);
651
+ room.state = 'waiting';
652
+ io.to(socket.roomId).emit('gameAborted', { reason: 'Not enough players to continue' });
653
+ }
654
+ });
655
+ });
656
+
657
+ server.listen(PORT, '0.0.0.0', () => {
658
+ console.log(`🎨 Drawable running at http://localhost:${PORT}`);
659
+ });
words.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ animals: [
3
+ 'cat','dog','elephant','giraffe','penguin','dolphin','tiger','parrot',
4
+ 'kangaroo','flamingo','octopus','cheetah','gorilla','panda','zebra',
5
+ 'crocodile','chameleon','hedgehog','platypus','narwhal','sloth','axolotl',
6
+ 'capybara','quokka','mantis shrimp','peacock','pelican','armadillo','pangolin'
7
+ ],
8
+ food: [
9
+ 'pizza','sushi','hamburger','taco','spaghetti','pancakes','waffle',
10
+ 'croissant','donut','avocado','strawberry','watermelon','pineapple',
11
+ 'burrito','cheesecake','ramen','pretzel','cupcake','lasagna','fondue',
12
+ 'baguette','churro','dumpling','falafel','gelato','mochi','baklava',
13
+ 'ceviche','paella','tiramisu'
14
+ ],
15
+ objects: [
16
+ 'umbrella','telescope','guitar','bicycle','lighthouse','compass','hourglass',
17
+ 'magnifying glass','parachute','anchor','trophy','lantern','typewriter',
18
+ 'binoculars','thermometer','calendar','backpack','microscope','skateboard',
19
+ 'boomerang','periscope','harmonica','sextant','abacus','kazoo','trebuchet',
20
+ 'gyroscope','kaleidoscope','protractor','metronome'
21
+ ],
22
+ nature: [
23
+ 'volcano','rainbow','tornado','glacier','waterfall','desert','cactus',
24
+ 'snowflake','lightning','mountain','coral reef','aurora','canyon','geyser',
25
+ 'tsunami','avalanche','quicksand','tundra','mangrove','stalactite',
26
+ 'fjord','monsoon','eclipse','meteor','geode','sinkhole','mesa','atoll',
27
+ 'permafrost','bioluminescence'
28
+ ],
29
+ movies: [
30
+ 'ghost','alien','matrix','titanic','inception','avatar','frozen','jumanji',
31
+ 'batman','spiderman','mermaid','pirates','dinosaur','wizard','dragon',
32
+ 'vampire','zombie','robot','spaceship','werewolf','mummy','superhero',
33
+ 'ninja','samurai','cowboy','gladiator','astronaut','spy','detective','witch'
34
+ ],
35
+ sports: [
36
+ 'basketball','volleyball','surfing','archery','gymnastics','wrestling',
37
+ 'fencing','polo','curling','javelin','hurdles','bobsled','trampoline',
38
+ 'skydiving','kayaking','rugby','lacrosse','badminton','cricket','handball',
39
+ 'parkour','breakdancing','chess boxing','sumo','kabaddi','sepak takraw',
40
+ 'disc golf','underwater hockey','toe wrestling','wife carrying'
41
+ ],
42
+ science: [
43
+ 'atom','black hole','DNA','rocket','satellite','supernova','fossil',
44
+ 'magnet','prism','laser','submarine','microscope','clone','hologram',
45
+ 'wormhole','neutron star','quasar','antimatter','dark matter','neutrino',
46
+ 'hadron','plasma','photon','entropy','relativity','quantum','singularity',
47
+ 'nebula','pulsar','event horizon'
48
+ ],
49
+ places: [
50
+ 'pyramid','eiffel tower','colosseum','statue of liberty','great wall',
51
+ 'stonehenge','taj mahal','machu picchu','niagara falls','amazon rainforest',
52
+ 'sahara','antarctica','bermuda triangle','area 51','alcatraz','pompeii',
53
+ 'mount everest','mariana trench','angkor wat','chichen itza','petra',
54
+ 'mount olympus','silicon valley','bermuda','dead sea','gobi desert',
55
+ 'great barrier reef','yellowstone','grand canyon','venice'
56
+ ],
57
+ fantasy: [
58
+ 'dragon','wizard','unicorn','phoenix','mermaid','griffin','centaur',
59
+ 'minotaur','basilisk','kraken','leviathan','hydra','cerberus','cyclops',
60
+ 'elf','dwarf','orc','hobbit','troll','goblin','fairy','vampire','werewolf',
61
+ 'golem','sphinx','chimera','manticore','banshee','kitsune','tengu'
62
+ ],
63
+ technology: [
64
+ 'smartphone','laptop','wifi','blockchain','artificial intelligence',
65
+ 'virtual reality','drone','3d printer','self driving car','smartwatch',
66
+ 'solar panel','wind turbine','electric car','robot arm','touchscreen',
67
+ 'satellite dish','fiber optic','quantum computer','neural network','chatbot',
68
+ 'cryptocurrency','hacker','data center','cloud','algorithm','pixel',
69
+ 'circuit board','microchip','binary code','augmented reality'
70
+ ]
71
+ };