Monorepo for Aesthetic.Computer
aesthetic.computer
1// Duel Manager, 2026.03.30
2// Server-authoritative game logic for dumduel.
3// Quake 3-style: server owns state, clients send inputs, server broadcasts snapshots.
4
5const ARENA_W = 220;
6const ARENA_H = 220;
7const BULLET_SPEED = 0.7;
8const MOVE_SPEED = 1.0;
9const HIT_R = 7;
10const COUNTDOWN_TICKS = 180; // 3s at 60Hz
11const ROUND_OVER_TICKS = 120; // 2s
12const TICK_RATE = 60; // server sim Hz
13const SNAPSHOT_INTERVAL = 3; // send snapshot every N ticks (~20Hz)
14const BULLET_MAX_AGE = 200;
15const DUMMY_HANDLE = "dummy";
16
17function norm(dx, dy) {
18 const len = Math.sqrt(dx * dx + dy * dy);
19 if (len < 0.001) return { nx: 0, ny: 0 };
20 return { nx: dx / len, ny: dy / len };
21}
22
23export class DuelManager {
24 constructor() {
25 this.players = new Map(); // handle -> PlayerRecord
26 this.spectators = new Map(); // handle -> { wsId }
27 this.roster = []; // handles in queue order
28 this.phase = "waiting";
29 this.tick = 0;
30 this.countdownTimer = 0;
31 this.roundOverTimer = 0;
32 this.roundWinner = null;
33 this.bullets = [];
34 this.tickInterval = null;
35
36 // Send function callbacks (set by session.mjs)
37 this.sendUDP = null; // (channelId, event, data)
38 this.sendWS = null; // (wsId, type, content)
39 this.broadcastWS = null; // (type, content)
40 this.resolveUdpForHandle = null; // (handle) -> channelId|null
41 }
42
43 // Called by session.mjs to wire up transport
44 setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) {
45 this.sendUDP = sendUDP;
46 this.sendWS = sendWS;
47 this.broadcastWS = broadcastWS;
48 this.resolveUdpForHandle = resolveUdpForHandle;
49 }
50
51 // -- Player Management --
52
53 playerJoin(handle, wsId) {
54 if (!handle) return;
55 // Guests spectate — track their wsId so they receive snapshots
56 if (handle.startsWith("guest_")) {
57 this.spectators.set(handle, { wsId });
58 // Send current state so they see the game immediately
59 this.sendWS?.(wsId, "duel:joined", {
60 roster: this.roster,
61 phase: this.phase,
62 spectator: true,
63 });
64 console.log(`🎯 Duel: ${handle} joined as spectator (${this.spectators.size} spectators)`);
65 return;
66 }
67
68 // Update existing or create new
69 let player = this.players.get(handle);
70 if (player) {
71 player.wsId = wsId;
72 } else {
73 player = {
74 handle,
75 wsId,
76 udpChannelId: null,
77 x: 0, y: 0,
78 targetX: 0, targetY: 0,
79 alive: true,
80 wasMoving: false,
81 lastInputSeq: 0,
82 ping: 0,
83 pingTs: 0,
84 };
85 this.players.set(handle, player);
86 }
87
88 // Add to roster if not already present
89 if (!this.roster.includes(handle)) {
90 this.roster.push(handle);
91 }
92
93 // Try to resolve UDP channel
94 this.tryResolveUdp(handle);
95
96 // Send current state to joiner
97 this.sendWS?.(wsId, "duel:joined", {
98 roster: this.roster,
99 phase: this.phase,
100 });
101
102 // Broadcast updated roster to all
103 this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase });
104
105 console.log(`🎯 Duel: ${handle} joined. Roster: [${this.roster.join(", ")}]`);
106
107 // Start game if we have enough players
108 this.checkStart();
109 }
110
111 playerLeave(handle) {
112 if (!handle) return;
113 if (this.spectators.has(handle)) {
114 this.spectators.delete(handle);
115 console.log(`🎯 Duel: spectator ${handle} left (${this.spectators.size} spectators)`);
116 return;
117 }
118 const wasInRoster = this.roster.includes(handle);
119 const wasDueling = this.isDuelist(handle);
120
121 this.roster = this.roster.filter((h) => h !== handle);
122 this.players.delete(handle);
123
124 // Remove dummy if it was paired with the leaving player
125 if (this.roster.includes(DUMMY_HANDLE) && this.roster.length <= 1) {
126 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE);
127 this.players.delete(DUMMY_HANDLE);
128 }
129
130 if (wasDueling && (this.phase === "fight" || this.phase === "countdown")) {
131 // Opponent wins by default
132 const remaining = this.getDuelists().find((h) => h !== handle);
133 if (remaining && remaining !== DUMMY_HANDLE) {
134 this.endRound(remaining);
135 } else {
136 this.resetToWaiting();
137 }
138 }
139
140 if (wasInRoster) {
141 this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase });
142 console.log(`🎯 Duel: ${handle} left. Roster: [${this.roster.join(", ")}]`);
143 }
144
145 this.checkStart();
146
147 // Stop tick if nobody left
148 if (this.roster.filter((h) => h !== DUMMY_HANDLE).length === 0) {
149 this.stopTick();
150 }
151 }
152
153 resolveUdpChannel(handle, channelId) {
154 const player = this.players.get(handle);
155 if (player) {
156 player.udpChannelId = channelId;
157 }
158 }
159
160 tryResolveUdp(handle) {
161 if (!this.resolveUdpForHandle) return;
162 const channelId = this.resolveUdpForHandle(handle);
163 if (channelId) {
164 const player = this.players.get(handle);
165 if (player) player.udpChannelId = channelId;
166 }
167 }
168
169 // -- Input Processing --
170
171 receiveInput(handle, input) {
172 const player = this.players.get(handle);
173 if (!player || !player.alive) return;
174 if (!this.isDuelist(handle)) return;
175 if (this.phase !== "fight" && this.phase !== "countdown") return;
176
177 player.targetX = Math.max(6, Math.min(ARENA_W - 6, input.targetX));
178 player.targetY = Math.max(6, Math.min(ARENA_H - 6, input.targetY));
179 if (input.seq > player.lastInputSeq) {
180 player.lastInputSeq = input.seq;
181 }
182 // Log first few inputs
183 if (input.seq <= 3) {
184 console.log(`🎯 Input from ${handle}: seq=${input.seq} target=(${input.targetX.toFixed(1)}, ${input.targetY.toFixed(1)})`);
185 }
186 }
187
188 handlePing(handle, ts, wsId) {
189 const player = this.players.get(handle);
190 if (player) {
191 player.ping = Date.now() - ts;
192 }
193 this.sendWS?.(wsId, "duel:pong", { ts, serverTime: Date.now() });
194 }
195
196 // -- Game Logic --
197
198 getDuelists() {
199 if (this.roster.length < 2) return [];
200 return [this.roster[0], this.roster[1]];
201 }
202
203 isDuelist(handle) {
204 const d = this.getDuelists();
205 return d.includes(handle);
206 }
207
208 checkStart() {
209 const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE);
210
211 if (realPlayers.length === 0) {
212 this.resetToWaiting();
213 this.stopTick();
214 return;
215 }
216
217 if (realPlayers.length === 1) {
218 // Solo — start practice with dummy (regardless of current phase)
219 // Remove dummy first if stale
220 if (this.roster.includes(DUMMY_HANDLE)) {
221 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE);
222 this.players.delete(DUMMY_HANDLE);
223 }
224 this.bullets = [];
225 this.phase = "waiting"; // reset phase so startPractice works
226 this.startPractice(realPlayers[0]);
227 return;
228 }
229
230 // Remove dummy if real opponent available
231 if (realPlayers.length >= 2 && this.roster.includes(DUMMY_HANDLE)) {
232 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE);
233 this.players.delete(DUMMY_HANDLE);
234 this.bullets = [];
235 }
236
237 if (this.roster.length >= 2 && (this.phase === "waiting" || this.phase === "roundover")) {
238 this.startCountdown();
239 }
240 }
241
242 startPractice(handle) {
243 // Add dummy
244 if (!this.roster.includes(DUMMY_HANDLE)) {
245 this.roster.push(DUMMY_HANDLE);
246 this.players.set(DUMMY_HANDLE, {
247 handle: DUMMY_HANDLE,
248 wsId: null,
249 udpChannelId: null,
250 x: ARENA_W - 30, y: ARENA_H - 30,
251 targetX: ARENA_W - 30, targetY: ARENA_H - 30,
252 alive: true,
253 wasMoving: false,
254 lastInputSeq: 0,
255 ping: 0,
256 pingTs: 0,
257 });
258 }
259
260 // Ensure handle is first in roster
261 this.roster = this.roster.filter((h) => h !== handle && h !== DUMMY_HANDLE);
262 this.roster.unshift(handle);
263 this.roster.push(DUMMY_HANDLE);
264
265 this.startCountdown();
266 }
267
268 startCountdown() {
269 this.phase = "countdown";
270 this.countdownTimer = COUNTDOWN_TICKS;
271 this.bullets = [];
272 this.roundWinner = null;
273
274 const duelists = this.getDuelists();
275 // Deterministic slots: alphabetical order
276 const sorted = [...duelists].sort();
277 const spawnA = { x: 30, y: 30 };
278 const spawnB = { x: ARENA_W - 30, y: ARENA_H - 30 };
279
280 for (const h of duelists) {
281 const p = this.players.get(h);
282 if (!p) continue;
283 const spawn = h === sorted[0] ? spawnA : spawnB;
284 p.x = spawn.x; p.y = spawn.y;
285 p.targetX = spawn.x; p.targetY = spawn.y;
286 p.alive = true;
287 p.wasMoving = false;
288 }
289
290 this.broadcastWS?.("duel:countdown", {
291 duelists,
292 timer: this.countdownTimer,
293 });
294
295 console.log(`🎯 Duel countdown: ${duelists.join(" vs ")} (phase: ${this.phase})`);
296 this.ensureTick();
297 }
298
299 startFight() {
300 this.phase = "fight";
301 this.broadcastWS?.("duel:fight", {});
302 console.log(`🎯 Duel fight started! Tick loop active.`);
303 }
304
305 endRound(winnerHandle) {
306 this.roundWinner = winnerHandle;
307 this.phase = "roundover";
308 this.roundOverTimer = ROUND_OVER_TICKS;
309
310 const duelists = this.getDuelists();
311 const loser = duelists.find((h) => h !== winnerHandle) || "???";
312
313 // Mark loser dead
314 const loserPlayer = this.players.get(loser);
315 if (loserPlayer) loserPlayer.alive = false;
316
317 this.broadcastWS?.("duel:death", { victim: loser, killer: winnerHandle });
318 this.broadcastWS?.("duel:roundover", { winner: winnerHandle, loser });
319
320 console.log(`🎯 Duel round: ${winnerHandle} killed ${loser}`);
321 }
322
323 advanceStack() {
324 if (this.roster.length >= 2 && this.roundWinner) {
325 // Loser goes to bottom
326 const duelists = this.getDuelists();
327 const loserHandle = duelists.find((h) => h !== this.roundWinner);
328 if (loserHandle) {
329 this.roster = this.roster.filter((h) => h !== loserHandle);
330 this.roster.push(loserHandle);
331 }
332 }
333
334 this.roundWinner = null;
335 this.bullets = [];
336
337 this.broadcastWS?.("duel:advance", { roster: this.roster });
338
339 // Check what to do next
340 const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE);
341 if (realPlayers.length >= 2) {
342 this.startCountdown();
343 } else if (realPlayers.length === 1) {
344 // Remove dummy, restart practice
345 this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE);
346 this.players.delete(DUMMY_HANDLE);
347 this.phase = "waiting";
348 this.startPractice(realPlayers[0]);
349 } else {
350 this.resetToWaiting();
351 }
352 }
353
354 resetToWaiting() {
355 this.phase = "waiting";
356 this.bullets = [];
357 this.roundWinner = null;
358 this.countdownTimer = 0;
359 this.roundOverTimer = 0;
360 }
361
362 // -- Server Tick --
363
364 // Purge stale guest handles from roster
365 purgeGuests() {
366 const guests = this.roster.filter((h) => h.startsWith("guest_"));
367 for (const g of guests) {
368 this.roster = this.roster.filter((h) => h !== g);
369 this.players.delete(g);
370 console.log(`🎯 Purged stale guest: ${g}`);
371 }
372 }
373
374 ensureTick() {
375 if (!this.tickInterval) {
376 this.purgeGuests(); // Clean up any stale guests before starting
377 this.tickInterval = setInterval(() => this.serverTick(), 1000 / TICK_RATE);
378 console.log(`🎯 Duel tick loop started (${TICK_RATE}Hz, snapshot every ${SNAPSHOT_INTERVAL} ticks)`);
379 }
380 }
381
382 stopTick() {
383 if (this.tickInterval) {
384 clearInterval(this.tickInterval);
385 this.tickInterval = null;
386 console.log(`🎯 Duel tick loop stopped`);
387 }
388 this.resetToWaiting();
389 }
390
391 serverTick() {
392 this.tick++;
393
394 if (this.phase === "countdown") {
395 this.countdownTimer--;
396 this.tickDummy();
397 this.tickMovement();
398 if (this.countdownTimer <= 0) this.startFight();
399 }
400
401 if (this.phase === "fight") {
402 this.tickDummy();
403 this.tickMovement();
404 this.tickFireOnStop();
405 this.tickBullets();
406 this.tickHitDetection();
407 }
408
409 if (this.phase === "roundover") {
410 this.roundOverTimer--;
411 if (this.roundOverTimer <= 0) this.advanceStack();
412 }
413
414 // Broadcast snapshot at reduced rate
415 if (this.tick % SNAPSHOT_INTERVAL === 0) {
416 this.broadcastSnapshot();
417 }
418 }
419
420 tickDummy() {
421 const dummy = this.players.get(DUMMY_HANDLE);
422 if (!dummy || !dummy.alive) return;
423
424 // Wander every ~90 ticks
425 if (this.tick % 90 === 0) {
426 dummy.targetX = 20 + Math.random() * (ARENA_W - 40);
427 dummy.targetY = 20 + Math.random() * (ARENA_H - 40);
428 }
429 }
430
431 tickMovement() {
432 const duelists = this.getDuelists();
433 for (const h of duelists) {
434 const p = this.players.get(h);
435 if (!p || !p.alive) continue;
436
437 const dx = p.targetX - p.x;
438 const dy = p.targetY - p.y;
439 const dist = Math.sqrt(dx * dx + dy * dy);
440
441 if (dist > 2) {
442 const speed = h === DUMMY_HANDLE ? MOVE_SPEED * 0.7 : MOVE_SPEED;
443 p.x += (dx / dist) * speed;
444 p.y += (dy / dist) * speed;
445 }
446 }
447 }
448
449 tickFireOnStop() {
450 const duelists = this.getDuelists();
451 for (const h of duelists) {
452 if (h === DUMMY_HANDLE) continue; // dummy doesn't fire
453 const p = this.players.get(h);
454 if (!p || !p.alive) continue;
455
456 const dx = p.targetX - p.x;
457 const dy = p.targetY - p.y;
458 const isMoving = dx * dx + dy * dy > 4;
459
460 // Fire when transitioning from moving to stopped
461 if (p.wasMoving && !isMoving) {
462 const opHandle = duelists.find((d) => d !== h);
463 const op = opHandle ? this.players.get(opHandle) : null;
464 if (op && op.alive) {
465 const { nx, ny } = norm(op.x - p.x, op.y - p.y);
466 this.bullets.push({
467 x: p.x + nx * 6,
468 y: p.y + ny * 6,
469 vx: nx * BULLET_SPEED,
470 vy: ny * BULLET_SPEED,
471 ownerHandle: h,
472 age: 0,
473 });
474 console.log(`🎯 ${h} fired! bullets=${this.bullets.length}`);
475 this.broadcastSnapshot();
476 }
477 }
478
479 // Update wasMoving AFTER the fire check
480 p.wasMoving = isMoving;
481 }
482 }
483
484 tickBullets() {
485 for (let i = this.bullets.length - 1; i >= 0; i--) {
486 const b = this.bullets[i];
487 b.x += b.vx;
488 b.y += b.vy;
489 b.age++;
490
491 // Remove if off-arena or too old
492 if (
493 b.age > BULLET_MAX_AGE ||
494 b.x < -10 || b.x > ARENA_W + 10 ||
495 b.y < -10 || b.y > ARENA_H + 10
496 ) {
497 this.bullets.splice(i, 1);
498 }
499 }
500 }
501
502 tickHitDetection() {
503 const duelists = this.getDuelists();
504 for (let i = this.bullets.length - 1; i >= 0; i--) {
505 const b = this.bullets[i];
506 // Check against non-owner duelist
507 for (const h of duelists) {
508 if (h === b.ownerHandle) continue;
509 const p = this.players.get(h);
510 if (!p || !p.alive) continue;
511
512 const dx = b.x - p.x;
513 const dy = b.y - p.y;
514 if (dx * dx + dy * dy < HIT_R * HIT_R) {
515 // Hit! Server-authoritative kill
516 this.bullets.splice(i, 1);
517 this.endRound(b.ownerHandle);
518 return; // only one kill per tick
519 }
520 }
521 }
522 }
523
524 // -- Snapshot Broadcasting --
525
526 broadcastSnapshot() {
527 const duelists = this.getDuelists();
528 const playersData = duelists.map((h) => {
529 const p = this.players.get(h);
530 if (!p) return null;
531 return {
532 handle: h,
533 x: Math.round(p.x * 10) / 10,
534 y: Math.round(p.y * 10) / 10,
535 targetX: Math.round(p.targetX * 10) / 10,
536 targetY: Math.round(p.targetY * 10) / 10,
537 alive: p.alive,
538 ping: p.ping,
539 };
540 }).filter(Boolean);
541
542 const bulletsData = this.bullets.map((b) => ({
543 x: Math.round(b.x * 10) / 10,
544 y: Math.round(b.y * 10) / 10,
545 vx: b.vx,
546 vy: b.vy,
547 owner: b.ownerHandle,
548 age: b.age,
549 }));
550
551 const lastInputSeq = {};
552 for (const h of duelists) {
553 const p = this.players.get(h);
554 if (p) lastInputSeq[h] = p.lastInputSeq;
555 }
556
557 const snapshot = {
558 tick: this.tick,
559 phase: this.phase,
560 countdownTimer: this.countdownTimer,
561 roundOverTimer: this.roundOverTimer,
562 roundWinner: this.roundWinner,
563 players: playersData,
564 bullets: bulletsData,
565 roster: this.roster,
566 lastInputSeq,
567 };
568
569 const data = JSON.stringify(snapshot);
570
571 // Log periodic snapshot info + always log when bullets present
572 if (this.tick % 300 === 0 || bulletsData.length > 0) {
573 const channels = [];
574 for (const [h, p] of this.players) {
575 if (h === DUMMY_HANDLE) continue;
576 channels.push(`${h}:${p.udpChannelId ? "UDP" : p.wsId != null ? "WS" : "NONE"}`);
577 }
578 console.log(`🎯 Duel snapshot #${this.tick} phase=${this.phase} bullets=${bulletsData.length} via [${channels.join(", ")}]`);
579 }
580
581 // Always send via WS for reliability (UDP was silently dropping packets)
582 for (const [handle, player] of this.players) {
583 if (handle === DUMMY_HANDLE) continue;
584 if (player.wsId != null && this.sendWS) {
585 this.sendWS(player.wsId, "duel:snapshot", snapshot);
586 }
587 }
588 // Send to spectators too
589 for (const [, spec] of this.spectators) {
590 if (spec.wsId != null && this.sendWS) {
591 this.sendWS(spec.wsId, "duel:snapshot", snapshot);
592 }
593 }
594 }
595}