VISHAL18for4 commited on
Commit
f9dc27f
Β·
verified Β·
1 Parent(s): 6a74d1e

Upload server.js

Browse files
Files changed (1) hide show
  1. server.js +771 -0
server.js ADDED
@@ -0,0 +1,771 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ // /game now served by index.html (single page app)
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: 10,
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
+ if (room.players.length === 0) return;
163
+ room.state = 'playing';
164
+ room.currentRound = 1;
165
+ room.scoreHistory = [];
166
+ room.usedWords = [];
167
+ room.players.forEach(p => {
168
+ p.score = 0; p.streak = 0; p.achievements = [];
169
+ p.powerUps = { revealLetter: 1, timeWarp: 1, bomb: 1 };
170
+ });
171
+
172
+ if (room.settings.teamMode) assignTeams(room);
173
+
174
+ room.drawerOrder = shuffle(room.players.map(p => p.id));
175
+ room.currentDrawerIndex = 0;
176
+ startRound(room);
177
+ }
178
+
179
+ function assignTeams(room) {
180
+ room.teams = { red: [], blue: [] };
181
+ room.players.forEach((p, i) => {
182
+ p.team = i % 2 === 0 ? 'red' : 'blue';
183
+ room.teams[p.team].push(p.id);
184
+ });
185
+ }
186
+
187
+ function startRound(room) {
188
+ clearAllTimers(room);
189
+ room.players.forEach(p => { p.hasGuessed = false; p.isDrawing = false; p.guessTime = null; });
190
+ room.drawStrokes = [];
191
+ room.hintsRevealed = [];
192
+ room.roundScores = [];
193
+ room.currentWord = null;
194
+
195
+ // Find drawer, skip if missing
196
+ let drawerFound = false;
197
+ while (!drawerFound && room.currentDrawerIndex < room.drawerOrder.length) {
198
+ const drawerId = room.drawerOrder[room.currentDrawerIndex];
199
+ const drawer = room.players.find(p => p.id === drawerId);
200
+ if (drawer) { drawer.isDrawing = true; drawerFound = true; }
201
+ else room.currentDrawerIndex++;
202
+ }
203
+
204
+ if (!drawerFound) { nextTurn(room); return; }
205
+
206
+ const drawer = room.players.find(p => p.isDrawing);
207
+ const choices = getWords(room.settings, room.settings.wordChoices);
208
+ room.wordChoices = choices;
209
+ room.state = 'choosing';
210
+
211
+ io.to(room.id).emit('roundStart', {
212
+ round: room.currentRound,
213
+ totalRounds: room.settings.rounds,
214
+ drawer: { id: drawer.id, name: drawer.name, avatar: drawer.avatar },
215
+ players: room.players,
216
+ });
217
+
218
+ io.to(drawer.id).emit('chooseWord', { choices });
219
+
220
+ room.chooseTimer = setTimeout(() => {
221
+ if (room.state === 'choosing') {
222
+ wordChosen(room, choices[Math.floor(Math.random() * choices.length)]);
223
+ }
224
+ }, 15000);
225
+ }
226
+
227
+ function wordChosen(room, word) {
228
+ clearAllTimers(room);
229
+ room.currentWord = word;
230
+ room.usedWords.push(word);
231
+ room.state = 'drawing';
232
+
233
+ const drawTime = room.settings.quickDraw ? 20 : room.settings.drawTime;
234
+ room.timeLeft = drawTime;
235
+ room.totalTime = drawTime;
236
+ room.roundStartTime = Date.now();
237
+
238
+ const drawer = room.players.find(p => p.isDrawing);
239
+ const mask = hintMask(word, []);
240
+ const lengths = word.split(' ').map(w => w.length);
241
+
242
+ io.to(drawer.id).emit('wordChosen', { word, hint: mask, lengths, isDrawer: true });
243
+ socket_to_room_except(room.id, drawer.id, 'wordChosen', { word: null, hint: mask, lengths, isDrawer: false });
244
+
245
+ // Auto hints
246
+ if (room.settings.hintsEnabled) {
247
+ room.hintTimer1 = setTimeout(() => { if (room.state === 'drawing') revealHint(room); }, drawTime * 500);
248
+ room.hintTimer2 = setTimeout(() => { if (room.state === 'drawing') revealHint(room); }, drawTime * 750);
249
+ }
250
+
251
+ // Countdown
252
+ room.timer = setInterval(() => {
253
+ room.timeLeft--;
254
+ io.to(room.id).emit('timerUpdate', { timeLeft: room.timeLeft, totalTime: room.totalTime });
255
+ if (room.timeLeft <= 0) endRound(room);
256
+ }, 1000);
257
+ }
258
+
259
+ function socket_to_room_except(roomId, exceptId, event, data) {
260
+ io.to(roomId).except(exceptId).emit(event, data);
261
+ }
262
+
263
+ function revealHint(room) {
264
+ const word = room.currentWord;
265
+ if (!word) return;
266
+ const unrevealed = [];
267
+ word.split('').forEach((c, i) => { if (c !== ' ' && !room.hintsRevealed.includes(i)) unrevealed.push(i); });
268
+ if (unrevealed.length === 0) return;
269
+ const n = Math.max(1, Math.floor(unrevealed.length * 0.3));
270
+ const picks = shuffle([...unrevealed]).slice(0, n);
271
+ room.hintsRevealed.push(...picks);
272
+ const drawer = room.players.find(p => p.isDrawing);
273
+ const hint = hintMask(word, room.hintsRevealed);
274
+ socket_to_room_except(room.id, drawer?.id || '', 'hintUpdate', { hint, revealed: room.hintsRevealed });
275
+ }
276
+
277
+ function checkGuess(room, sid, text) {
278
+ if (!room.currentWord || room.state !== 'drawing') return 'ignore';
279
+ const player = room.players.find(p => p.id === sid);
280
+ if (!player || player.hasGuessed || player.isDrawing) return 'ignore';
281
+
282
+ const guess = text.trim().toLowerCase();
283
+ const answer = room.currentWord.toLowerCase();
284
+
285
+ if (guess === answer) {
286
+ const elapsed = (Date.now() - room.roundStartTime) / 1000;
287
+ const timeRatio = Math.max(0, 1 - elapsed / room.totalTime);
288
+ const baseScore = 200;
289
+ const timeBonus = Math.round(timeRatio * 300);
290
+ const firstBonus = room.roundScores.length === 0 ? 50 : 0;
291
+ const streakBonus = player.streak * 25;
292
+ const total = baseScore + timeBonus + firstBonus + streakBonus;
293
+
294
+ player.hasGuessed = true;
295
+ player.guessTime = elapsed;
296
+ player.score += total;
297
+ player.streak++;
298
+ player.wordsDrawn;
299
+
300
+ const drawer = room.players.find(p => p.isDrawing);
301
+ if (drawer) {
302
+ const drawerGain = Math.round(50 + (room.roundScores.length === 0 ? 0 : 10));
303
+ drawer.score += drawerGain;
304
+ }
305
+
306
+ room.roundScores.push({ id: sid, score: total, time: elapsed });
307
+ checkAchievements(room, player);
308
+
309
+ const nonDrawers = room.players.filter(p => !p.isDrawing && !p.isSpectator);
310
+ if (nonDrawers.every(p => p.hasGuessed)) setTimeout(() => endRound(room), 1500);
311
+
312
+ return { correct: true, score: total, streak: player.streak, firstBonus };
313
+ }
314
+
315
+ const dist = levenshtein(guess, answer);
316
+ if (dist <= 2 && guess.length > 2) return 'close';
317
+
318
+ if (room.settings.suddenDeath) {
319
+ player.score = Math.max(0, player.score - 15);
320
+ broadcastPlayers(room);
321
+ }
322
+ return 'wrong';
323
+ }
324
+
325
+ function checkAchievements(room, player) {
326
+ const unlocked = [];
327
+ const add = (id, name, desc) => {
328
+ if (!player.achievements.includes(id)) {
329
+ player.achievements.push(id);
330
+ unlocked.push({ id, name, desc });
331
+ }
332
+ };
333
+ if (player.guessTime < 8) add('speed_demon', '⚑ Speed Demon', 'Guessed in under 8 seconds!');
334
+ if (player.streak >= 3) add('on_fire', 'πŸ”₯ On Fire', '3 correct guesses in a row!');
335
+ if (player.streak >= 5) add('unstoppable', 'πŸ’Ž Unstoppable', '5 in a row!');
336
+ if (room.roundScores.length === 1) add('first_blood', '🩸 First Blood', 'First to guess the word!');
337
+ if (room.hintsRevealed.length === 0 && player.guessTime < room.totalTime * 0.5)
338
+ add('no_hints', 'πŸ•΅οΈ Sherlock', 'Guessed with zero hints revealed!');
339
+ if (unlocked.length > 0) io.to(player.id).emit('achievementUnlocked', unlocked);
340
+ }
341
+
342
+ function endRound(room) {
343
+ clearAllTimers(room);
344
+ if (room.state === 'intermission' || room.state === 'ended') return;
345
+ room.state = 'intermission';
346
+
347
+ const drawer = room.players.find(p => p.isDrawing);
348
+
349
+ // Picasso achievement
350
+ const nonDrawers = room.players.filter(p => !p.isDrawing && !p.isSpectator);
351
+ if (drawer && nonDrawers.length > 0 && nonDrawers.every(p => p.hasGuessed)) {
352
+ if (!drawer.achievements.includes('picasso')) {
353
+ drawer.achievements.push('picasso');
354
+ io.to(drawer.id).emit('achievementUnlocked', [{ id: 'picasso', name: '🎨 Picasso', desc: 'Everyone guessed your drawing!' }]);
355
+ }
356
+ drawer.score += 100; // bonus
357
+ }
358
+
359
+ // Record score history
360
+ room.scoreHistory.push({
361
+ round: room.currentRound,
362
+ drawerTurn: room.currentDrawerIndex,
363
+ scores: room.players.map(p => ({ id: p.id, name: p.name, score: p.score }))
364
+ });
365
+
366
+ io.to(room.id).emit('roundEnd', {
367
+ word: room.currentWord,
368
+ scores: room.players.map(p => ({
369
+ id: p.id, name: p.name, score: p.score, avatar: p.avatar,
370
+ roundGain: room.roundScores.find(r => r.id === p.id)?.score || 0,
371
+ guessTime: p.guessTime,
372
+ })),
373
+ drawStrokes: room.drawStrokes,
374
+ drawer: drawer ? { id: drawer.id, name: drawer.name } : null,
375
+ scoreHistory: room.scoreHistory,
376
+ });
377
+
378
+ room.timer = setTimeout(() => {
379
+ if (!rooms[room.id]) return;
380
+ nextTurn(room);
381
+ }, 7000);
382
+ }
383
+
384
+ function nextTurn(room) {
385
+ clearAllTimers(room);
386
+ room.currentDrawerIndex++;
387
+
388
+ if (room.currentDrawerIndex >= room.drawerOrder.length) {
389
+ room.currentRound++;
390
+ if (room.currentRound > room.settings.rounds) { endGame(room); return; }
391
+ room.drawerOrder = shuffle(room.players.map(p => p.id));
392
+ room.currentDrawerIndex = 0;
393
+ }
394
+
395
+ if (room.players.length < 1) {
396
+ room.state = 'waiting';
397
+ io.to(room.id).emit('gameAborted', { reason: 'Not enough players' });
398
+ return;
399
+ }
400
+ startRound(room);
401
+ }
402
+
403
+ function endGame(room) {
404
+ clearAllTimers(room);
405
+ room.state = 'ended';
406
+
407
+ const sorted = [...room.players].sort((a, b) => b.score - a.score);
408
+
409
+ // Victory achievement
410
+ if (sorted[0]) {
411
+ const winner = sorted[0];
412
+ if (!winner.achievements.includes('champion')) {
413
+ winner.achievements.push('champion');
414
+ io.to(winner.id).emit('achievementUnlocked', [{ id: 'champion', name: 'πŸ‘‘ Champion', desc: 'You won the game!' }]);
415
+ }
416
+ }
417
+
418
+ io.to(room.id).emit('gameEnd', {
419
+ players: sorted.map((p, i) => ({
420
+ id: p.id, name: p.name, score: p.score,
421
+ rank: i + 1, achievements: p.achievements, avatar: p.avatar, streak: p.streak
422
+ })),
423
+ scoreHistory: room.scoreHistory,
424
+ });
425
+
426
+ setTimeout(() => { if (rooms[room.id]) delete rooms[room.id]; }, 60000);
427
+ }
428
+
429
+ // ─── Socket.IO ───────────────────────────────────────────────────────────────
430
+ io.on('connection', (socket) => {
431
+
432
+ socket.on('createRoom', ({ name, avatar, settings }) => {
433
+ const id = createRoom(settings || {});
434
+ const room = rooms[id];
435
+ room.host = socket.id;
436
+ const player = addPlayer(room, socket.id, name, avatar);
437
+ socket.join(id);
438
+ socket.roomId = id;
439
+ socket.emit('roomCreated', { roomId: id, player, room: getRoomData(room) });
440
+ });
441
+
442
+ socket.on('joinRoom', ({ roomId, name, avatar, asSpectator }) => {
443
+ const code = (roomId || '').toUpperCase().trim();
444
+ const room = rooms[code];
445
+ if (!room) { socket.emit('joinError', { message: 'Room not found. Check the code!' }); return; }
446
+ if (room.players.length >= room.settings.maxPlayers) {
447
+ socket.emit('joinError', { message: 'Room is full! (max ' + room.settings.maxPlayers + ')' }); return;
448
+ }
449
+
450
+ socket.join(code);
451
+ socket.roomId = code;
452
+
453
+ let player;
454
+ if (asSpectator) {
455
+ player = addPlayer(room, socket.id, name, avatar);
456
+ player.isSpectator = true;
457
+ room.players = room.players.filter(p => p.id !== socket.id);
458
+ room.spectators.push(player);
459
+ } else {
460
+ player = addPlayer(room, socket.id, name, avatar);
461
+ // If joining mid-game, mark as late joiner (they wait for next round to draw)
462
+ if (room.state !== 'waiting') {
463
+ player.lateJoin = true;
464
+ }
465
+ }
466
+
467
+ socket.emit('roomJoined', { player, room: getRoomData(room) });
468
+ socket.to(code).emit('playerJoined', { player, players: room.players, spectators: room.spectators });
469
+ });
470
+
471
+ socket.on('joinRandom', ({ name, avatar }) => {
472
+ // Find existing matchmaking room
473
+ let room = Object.values(rooms).find(r =>
474
+ r.state === 'waiting' &&
475
+ r.isMatchmaking === true &&
476
+ r.players.length < r.settings.maxPlayers
477
+ );
478
+
479
+ if (!room) {
480
+ // Create a new matchmaking room
481
+ const id = createRoom();
482
+ room = rooms[id];
483
+ room.isMatchmaking = true;
484
+ room.host = socket.id;
485
+ }
486
+
487
+ const player = addPlayer(room, socket.id, name, avatar);
488
+ socket.join(room.id);
489
+ socket.roomId = room.id;
490
+
491
+ // If first player, set as host
492
+ if (room.players.length === 1) room.host = socket.id;
493
+
494
+ socket.emit('roomJoined', { player, room: getRoomData(room) });
495
+ socket.to(room.id).emit('playerJoined', { player, players: room.players });
496
+
497
+ // Broadcast updated matchmaking count
498
+ io.to(room.id).emit('matchmakingUpdate', {
499
+ players: room.players.length,
500
+ needed: 2,
501
+ countdown: null
502
+ });
503
+
504
+ // Cancel any existing auto-start timer
505
+ if (room.matchmakingTimer) { clearTimeout(room.matchmakingTimer); room.matchmakingTimer = null; }
506
+ if (room.matchmakingCountdown) { clearInterval(room.matchmakingCountdown); room.matchmakingCountdown = null; }
507
+
508
+ if (room.players.length >= 2) {
509
+ // Start countdown (10s) to allow more players to join
510
+ let secs = 10;
511
+ io.to(room.id).emit('matchmakingUpdate', { players: room.players.length, needed: 2, countdown: secs });
512
+
513
+ room.matchmakingCountdown = setInterval(() => {
514
+ secs--;
515
+ io.to(room.id).emit('matchmakingUpdate', { players: room.players.length, needed: 2, countdown: secs });
516
+ if (secs <= 0) {
517
+ clearInterval(room.matchmakingCountdown);
518
+ room.matchmakingCountdown = null;
519
+ }
520
+ }, 1000);
521
+
522
+ room.matchmakingTimer = setTimeout(() => {
523
+ if (rooms[room.id] && room.state === 'waiting' && room.players.length >= 2) {
524
+ room.isMatchmaking = false;
525
+ startGame(room);
526
+ }
527
+ }, 10000);
528
+
529
+ } else {
530
+ // Only 1 player β€” wait up to 30s then start solo (practice) mode
531
+ io.to(room.id).emit('matchmakingUpdate', { players: 1, needed: 2, countdown: null, waiting: true });
532
+
533
+ room.matchmakingTimer = setTimeout(() => {
534
+ if (rooms[room.id] && room.state === 'waiting') {
535
+ if (room.players.length >= 2) {
536
+ room.isMatchmaking = false;
537
+ startGame(room);
538
+ } else {
539
+ // Solo mode - start with 1 player (they draw, no one guesses but they can practice)
540
+ room.isMatchmaking = false;
541
+ room.settings.rounds = 2;
542
+ startGame(room);
543
+ }
544
+ }
545
+ }, 8000);
546
+ }
547
+ });
548
+
549
+ // CRITICAL: When page reloads after game starts, new socket must rejoin room
550
+ socket.on('rejoinGame', ({ roomId, name, avatar }) => {
551
+ const code = (roomId || '').toUpperCase().trim();
552
+ const room = rooms[code];
553
+ if (!room) { socket.emit('rejoinError', { message: 'Room expired or not found' }); return; }
554
+
555
+ socket.join(code);
556
+ socket.roomId = code;
557
+
558
+ // Check if player with same name already exists (reconnect)
559
+ let player = room.players.find(p => p.name === name);
560
+ if (player) {
561
+ // Update their socket id to the new one
562
+ const oldId = player.id;
563
+ player.id = socket.id;
564
+ // Update host if needed
565
+ if (room.host === oldId) room.host = socket.id;
566
+ // Update drawerOrder
567
+ const di = room.drawerOrder.indexOf(oldId);
568
+ if (di !== -1) room.drawerOrder[di] = socket.id;
569
+ } else {
570
+ // New player joining mid-game
571
+ if (room.players.length >= room.settings.maxPlayers) {
572
+ socket.emit('rejoinError', { message: 'Room is full!' }); return;
573
+ }
574
+ player = addPlayer(room, socket.id, name, avatar);
575
+ player.lateJoin = true;
576
+ }
577
+
578
+ // Send current game state so they can sync up
579
+ const drawer = room.players.find(p => p.isDrawing);
580
+ socket.emit('gameRejoined', {
581
+ player,
582
+ room: getRoomData(room),
583
+ state: room.state,
584
+ round: room.currentRound,
585
+ totalRounds: room.settings.rounds,
586
+ drawer: drawer ? { id: drawer.id, name: drawer.name, avatar: drawer.avatar } : null,
587
+ players: room.players,
588
+ hint: room.currentWord ? hintMask(room.currentWord, room.hintsRevealed) : null,
589
+ timeLeft: room.timeLeft,
590
+ totalTime: room.totalTime,
591
+ drawStrokes: room.drawStrokes,
592
+ isDrawer: drawer?.id === socket.id,
593
+ word: drawer?.id === socket.id ? room.currentWord : null,
594
+ });
595
+
596
+ // Tell others this player is back
597
+ socket.to(code).emit('playerJoined', { player, players: room.players });
598
+ });
599
+
600
+ socket.on('startGame', () => {
601
+ const room = rooms[socket.roomId];
602
+ if (!room) return;
603
+ if (room.host !== socket.id) { socket.emit('gameError', { message: 'Only the host can start' }); return; }
604
+ if (room.players.length < 2) { socket.emit('gameError', { message: 'Need at least 2 players' }); return; }
605
+ startGame(room);
606
+ });
607
+
608
+ socket.on('chooseWord', ({ word }) => {
609
+ const room = rooms[socket.roomId];
610
+ if (!room || room.state !== 'choosing') return;
611
+ const player = room.players.find(p => p.id === socket.id);
612
+ if (!player?.isDrawing || !room.wordChoices.includes(word)) return;
613
+ wordChosen(room, word);
614
+ });
615
+
616
+ socket.on('draw', (data) => {
617
+ const room = rooms[socket.roomId];
618
+ if (!room || room.state !== 'drawing') return;
619
+ const player = room.players.find(p => p.id === socket.id);
620
+ if (!player?.isDrawing) return;
621
+ const stamped = { ...data, t: Date.now() - (room.roundStartTime || Date.now()) };
622
+ room.drawStrokes.push(stamped);
623
+ socket.to(socket.roomId).emit('draw', data);
624
+ });
625
+
626
+ socket.on('clearCanvas', () => {
627
+ const room = rooms[socket.roomId];
628
+ if (!room || room.state !== 'drawing') return;
629
+ const player = room.players.find(p => p.id === socket.id);
630
+ if (!player?.isDrawing) return;
631
+ room.drawStrokes = [{ type: 'clear', t: 0 }];
632
+ io.to(socket.roomId).emit('clearCanvas');
633
+ });
634
+
635
+ socket.on('guess', ({ text }) => {
636
+ const room = rooms[socket.roomId];
637
+ if (!room || !text) return;
638
+ const player = getPlayer(room, socket.id);
639
+ if (!player) return;
640
+
641
+ // Block drawer from revealing word in chat
642
+ if (player.isDrawing) {
643
+ socket.emit('chatMessage', { system: true, text: '🀫 You cannot chat while drawing!', type: 'warn' });
644
+ return;
645
+ }
646
+
647
+ const result = checkGuess(room, socket.id, text);
648
+
649
+ if (result === 'ignore') return;
650
+
651
+ if (result?.correct) {
652
+ socket.emit('correctGuess', { score: result.score, streak: result.streak, firstBonus: result.firstBonus });
653
+ io.to(socket.roomId).emit('chatMessage', {
654
+ system: true,
655
+ text: `πŸŽ‰ ${player.name} guessed it! (+${result.score}pts${result.streak > 1 ? ` πŸ”₯x${result.streak}` : ''})`,
656
+ type: 'correct'
657
+ });
658
+ broadcastPlayers(room);
659
+ } else if (result === 'close') {
660
+ socket.emit('chatMessage', { system: true, text: `🌑️ So close! Keep trying...`, type: 'close' });
661
+ io.to(socket.roomId).emit('chatMessage', { sender: player.name, text: '...', playerId: socket.id, type: 'guess' });
662
+ } else {
663
+ io.to(socket.roomId).emit('chatMessage', { sender: player.name, text, playerId: socket.id, type: 'guess' });
664
+ }
665
+ });
666
+
667
+ socket.on('chatMessage', ({ text }) => {
668
+ const room = rooms[socket.roomId];
669
+ if (!room || !text) return;
670
+ const player = getPlayer(room, socket.id);
671
+ if (!player) return;
672
+ // Only allow chat in non-drawing state or for spectators
673
+ if (room.state === 'drawing' && !player.isSpectator) {
674
+ // This goes through guess flow - ignore raw chat
675
+ return;
676
+ }
677
+ io.to(socket.roomId).emit('chatMessage', { sender: player.name, text: text.substring(0, 120), playerId: socket.id, type: 'chat' });
678
+ });
679
+
680
+ socket.on('usePowerUp', ({ type }) => {
681
+ const room = rooms[socket.roomId];
682
+ if (!room || room.state !== 'drawing') return;
683
+ const player = room.players.find(p => p.id === socket.id);
684
+ if (!player || player.isDrawing || !player.powerUps[type] || player.powerUps[type] <= 0) return;
685
+
686
+ player.powerUps[type]--;
687
+
688
+ if (type === 'revealLetter') {
689
+ revealHint(room);
690
+ io.to(socket.roomId).emit('chatMessage', { system: true, text: `πŸ” ${player.name} revealed a letter!`, type: 'powerup' });
691
+ } else if (type === 'timeWarp') {
692
+ room.timeLeft = Math.min(room.timeLeft + 15, room.totalTime);
693
+ io.to(socket.roomId).emit('timerUpdate', { timeLeft: room.timeLeft, totalTime: room.totalTime });
694
+ io.to(socket.roomId).emit('chatMessage', { system: true, text: `⏰ ${player.name} warped time! +15s`, type: 'powerup' });
695
+ } else if (type === 'bomb') {
696
+ room.drawStrokes = [{ type: 'clear', t: Date.now() - room.roundStartTime }];
697
+ io.to(socket.roomId).emit('clearCanvas');
698
+ io.to(socket.roomId).emit('chatMessage', { system: true, text: `πŸ’£ ${player.name} dropped a BOMB! Canvas cleared!`, type: 'powerup' });
699
+ }
700
+
701
+ socket.emit('powerUpUsed', { type, remaining: player.powerUps });
702
+ });
703
+
704
+ socket.on('reaction', ({ emoji }) => {
705
+ const room = rooms[socket.roomId];
706
+ if (!room) return;
707
+ const player = getPlayer(room, socket.id);
708
+ io.to(socket.roomId).emit('reaction', { emoji, name: player?.name || 'Someone' });
709
+ });
710
+
711
+ socket.on('updateSettings', (settings) => {
712
+ const room = rooms[socket.roomId];
713
+ if (!room || room.host !== socket.id || room.state !== 'waiting') return;
714
+ Object.assign(room.settings, settings);
715
+ io.to(socket.roomId).emit('settingsUpdated', room.settings);
716
+ });
717
+
718
+ socket.on('kickPlayer', ({ playerId }) => {
719
+ const room = rooms[socket.roomId];
720
+ if (!room || room.host !== socket.id) return;
721
+ const target = io.sockets.sockets.get(playerId);
722
+ if (target) {
723
+ target.emit('kicked', { reason: 'You were kicked by the host' });
724
+ target.leave(socket.roomId);
725
+ }
726
+ room.players = room.players.filter(p => p.id !== playerId);
727
+ io.to(socket.roomId).emit('playerLeft', { playerId, players: room.players });
728
+ });
729
+
730
+ socket.on('requestReplay', () => {
731
+ // Allow replay of last round strokes - sent on roundEnd already
732
+ });
733
+
734
+ socket.on('disconnect', () => {
735
+ const room = rooms[socket.roomId];
736
+ if (!room) return;
737
+
738
+ const wasDrawing = room.players.find(p => p.id === socket.id)?.isDrawing;
739
+ room.players = room.players.filter(p => p.id !== socket.id);
740
+ room.spectators = room.spectators.filter(p => p.id !== socket.id);
741
+
742
+ if (room.players.length === 0 && room.spectators.length === 0) {
743
+ clearAllTimers(room);
744
+ delete rooms[socket.roomId];
745
+ if (room.matchmakingTimer) clearTimeout(room.matchmakingTimer);
746
+ if (room.matchmakingCountdown) clearInterval(room.matchmakingCountdown);
747
+ return;
748
+ }
749
+
750
+ if (room.host === socket.id && room.players.length > 0) {
751
+ room.host = room.players[0].id;
752
+ io.to(room.host).emit('youAreHost');
753
+ }
754
+
755
+ io.to(socket.roomId).emit('playerLeft', { playerId: socket.id, players: room.players });
756
+
757
+ if (wasDrawing && (room.state === 'drawing' || room.state === 'choosing')) {
758
+ endRound(room);
759
+ }
760
+
761
+ if (room.players.length < 1 && room.state !== 'waiting' && room.state !== 'ended') {
762
+ clearAllTimers(room);
763
+ room.state = 'waiting';
764
+ io.to(socket.roomId).emit('gameAborted', { reason: 'Not enough players to continue' });
765
+ }
766
+ });
767
+ });
768
+
769
+ server.listen(PORT, '0.0.0.0', () => {
770
+ console.log(`🎨 Drawable running at http://localhost:${PORT}`);
771
+ });