Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

dumduel: server-authoritative netcode (Q3-style)

- New duel-manager.mjs: server owns game state, tick loop at 60Hz,
server-side hit detection, fire-on-stop, roster/round management,
dummy AI for solo practice
- session.mjs: hook DuelManager into WS + UDP handlers, store geckos
channel refs for targeted UDP sends, resolve handle↔channel correlation
- dumduel.mjs: thin client — sends inputs via UDP, renders server
snapshots, client-side prediction with input replay, ping display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1078 -675
+540
session-server/duel-manager.mjs
··· 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 + 5 + const ARENA_W = 220; 6 + const ARENA_H = 220; 7 + const BULLET_SPEED = 0.7; 8 + const MOVE_SPEED = 1.0; 9 + const HIT_R = 7; 10 + const COUNTDOWN_TICKS = 180; // 3s at 60Hz 11 + const ROUND_OVER_TICKS = 120; // 2s 12 + const TICK_RATE = 60; // server sim Hz 13 + const SNAPSHOT_INTERVAL = 3; // send snapshot every N ticks (~20Hz) 14 + const BULLET_MAX_AGE = 200; 15 + const DUMMY_HANDLE = "dummy"; 16 + 17 + function 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 + 23 + export class DuelManager { 24 + constructor() { 25 + this.players = new Map(); // handle -> PlayerRecord 26 + this.roster = []; // handles in queue order 27 + this.phase = "waiting"; 28 + this.tick = 0; 29 + this.countdownTimer = 0; 30 + this.roundOverTimer = 0; 31 + this.roundWinner = null; 32 + this.bullets = []; 33 + this.tickInterval = null; 34 + 35 + // Send function callbacks (set by session.mjs) 36 + this.sendUDP = null; // (channelId, event, data) 37 + this.sendWS = null; // (wsId, type, content) 38 + this.broadcastWS = null; // (type, content) 39 + this.resolveUdpForHandle = null; // (handle) -> channelId|null 40 + } 41 + 42 + // Called by session.mjs to wire up transport 43 + setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) { 44 + this.sendUDP = sendUDP; 45 + this.sendWS = sendWS; 46 + this.broadcastWS = broadcastWS; 47 + this.resolveUdpForHandle = resolveUdpForHandle; 48 + } 49 + 50 + // -- Player Management -- 51 + 52 + playerJoin(handle, wsId) { 53 + if (!handle) return; 54 + 55 + // Update existing or create new 56 + let player = this.players.get(handle); 57 + if (player) { 58 + player.wsId = wsId; 59 + } else { 60 + player = { 61 + handle, 62 + wsId, 63 + udpChannelId: null, 64 + x: 0, y: 0, 65 + targetX: 0, targetY: 0, 66 + alive: true, 67 + wasMoving: false, 68 + lastInputSeq: 0, 69 + ping: 0, 70 + pingTs: 0, 71 + }; 72 + this.players.set(handle, player); 73 + } 74 + 75 + // Add to roster if not already present 76 + if (!this.roster.includes(handle)) { 77 + this.roster.push(handle); 78 + } 79 + 80 + // Try to resolve UDP channel 81 + this.tryResolveUdp(handle); 82 + 83 + // Send current state to joiner 84 + this.sendWS?.(wsId, "duel:joined", { 85 + roster: this.roster, 86 + phase: this.phase, 87 + }); 88 + 89 + // Broadcast updated roster to all 90 + this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase }); 91 + 92 + console.log(`🎯 Duel: ${handle} joined. Roster: [${this.roster.join(", ")}]`); 93 + 94 + // Start game if we have enough players 95 + this.checkStart(); 96 + } 97 + 98 + playerLeave(handle) { 99 + if (!handle) return; 100 + const wasInRoster = this.roster.includes(handle); 101 + const wasDueling = this.isDuelist(handle); 102 + 103 + this.roster = this.roster.filter((h) => h !== handle); 104 + this.players.delete(handle); 105 + 106 + // Remove dummy if it was paired with the leaving player 107 + if (this.roster.includes(DUMMY_HANDLE) && this.roster.length <= 1) { 108 + this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); 109 + this.players.delete(DUMMY_HANDLE); 110 + } 111 + 112 + if (wasDueling && (this.phase === "fight" || this.phase === "countdown")) { 113 + // Opponent wins by default 114 + const remaining = this.getDuelists().find((h) => h !== handle); 115 + if (remaining && remaining !== DUMMY_HANDLE) { 116 + this.endRound(remaining); 117 + } else { 118 + this.resetToWaiting(); 119 + } 120 + } 121 + 122 + if (wasInRoster) { 123 + this.broadcastWS?.("duel:roster", { roster: this.roster, phase: this.phase }); 124 + console.log(`🎯 Duel: ${handle} left. Roster: [${this.roster.join(", ")}]`); 125 + } 126 + 127 + this.checkStart(); 128 + 129 + // Stop tick if nobody left 130 + if (this.roster.filter((h) => h !== DUMMY_HANDLE).length === 0) { 131 + this.stopTick(); 132 + } 133 + } 134 + 135 + resolveUdpChannel(handle, channelId) { 136 + const player = this.players.get(handle); 137 + if (player) { 138 + player.udpChannelId = channelId; 139 + } 140 + } 141 + 142 + tryResolveUdp(handle) { 143 + if (!this.resolveUdpForHandle) return; 144 + const channelId = this.resolveUdpForHandle(handle); 145 + if (channelId) { 146 + const player = this.players.get(handle); 147 + if (player) player.udpChannelId = channelId; 148 + } 149 + } 150 + 151 + // -- Input Processing -- 152 + 153 + receiveInput(handle, input) { 154 + const player = this.players.get(handle); 155 + if (!player || !player.alive) return; 156 + if (!this.isDuelist(handle)) return; 157 + if (this.phase !== "fight") return; 158 + 159 + player.targetX = Math.max(6, Math.min(ARENA_W - 6, input.targetX)); 160 + player.targetY = Math.max(6, Math.min(ARENA_H - 6, input.targetY)); 161 + if (input.seq > player.lastInputSeq) { 162 + player.lastInputSeq = input.seq; 163 + } 164 + } 165 + 166 + handlePing(handle, ts, wsId) { 167 + const player = this.players.get(handle); 168 + if (player) { 169 + player.ping = Date.now() - ts; 170 + } 171 + this.sendWS?.(wsId, "duel:pong", { ts, serverTime: Date.now() }); 172 + } 173 + 174 + // -- Game Logic -- 175 + 176 + getDuelists() { 177 + if (this.roster.length < 2) return []; 178 + return [this.roster[0], this.roster[1]]; 179 + } 180 + 181 + isDuelist(handle) { 182 + const d = this.getDuelists(); 183 + return d.includes(handle); 184 + } 185 + 186 + checkStart() { 187 + const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE); 188 + 189 + if (realPlayers.length === 0) { 190 + this.resetToWaiting(); 191 + return; 192 + } 193 + 194 + if (realPlayers.length === 1 && this.phase === "waiting") { 195 + // Solo — start practice with dummy 196 + this.startPractice(realPlayers[0]); 197 + return; 198 + } 199 + 200 + // Remove dummy if real opponent available 201 + if (realPlayers.length >= 2 && this.roster.includes(DUMMY_HANDLE)) { 202 + this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); 203 + this.players.delete(DUMMY_HANDLE); 204 + this.bullets = []; 205 + } 206 + 207 + if (this.roster.length >= 2 && this.phase === "waiting") { 208 + this.startCountdown(); 209 + } 210 + } 211 + 212 + startPractice(handle) { 213 + // Add dummy 214 + if (!this.roster.includes(DUMMY_HANDLE)) { 215 + this.roster.push(DUMMY_HANDLE); 216 + this.players.set(DUMMY_HANDLE, { 217 + handle: DUMMY_HANDLE, 218 + wsId: null, 219 + udpChannelId: null, 220 + x: ARENA_W - 30, y: ARENA_H - 30, 221 + targetX: ARENA_W - 30, targetY: ARENA_H - 30, 222 + alive: true, 223 + wasMoving: false, 224 + lastInputSeq: 0, 225 + ping: 0, 226 + pingTs: 0, 227 + }); 228 + } 229 + 230 + // Ensure handle is first in roster 231 + this.roster = this.roster.filter((h) => h !== handle && h !== DUMMY_HANDLE); 232 + this.roster.unshift(handle); 233 + this.roster.push(DUMMY_HANDLE); 234 + 235 + this.startCountdown(); 236 + } 237 + 238 + startCountdown() { 239 + this.phase = "countdown"; 240 + this.countdownTimer = COUNTDOWN_TICKS; 241 + this.bullets = []; 242 + this.roundWinner = null; 243 + 244 + const duelists = this.getDuelists(); 245 + // Deterministic slots: alphabetical order 246 + const sorted = [...duelists].sort(); 247 + const spawnA = { x: 30, y: 30 }; 248 + const spawnB = { x: ARENA_W - 30, y: ARENA_H - 30 }; 249 + 250 + for (const h of duelists) { 251 + const p = this.players.get(h); 252 + if (!p) continue; 253 + const spawn = h === sorted[0] ? spawnA : spawnB; 254 + p.x = spawn.x; p.y = spawn.y; 255 + p.targetX = spawn.x; p.targetY = spawn.y; 256 + p.alive = true; 257 + p.wasMoving = false; 258 + } 259 + 260 + this.broadcastWS?.("duel:countdown", { 261 + duelists, 262 + timer: this.countdownTimer, 263 + }); 264 + 265 + this.ensureTick(); 266 + } 267 + 268 + startFight() { 269 + this.phase = "fight"; 270 + this.broadcastWS?.("duel:fight", {}); 271 + } 272 + 273 + endRound(winnerHandle) { 274 + this.roundWinner = winnerHandle; 275 + this.phase = "roundover"; 276 + this.roundOverTimer = ROUND_OVER_TICKS; 277 + 278 + const duelists = this.getDuelists(); 279 + const loser = duelists.find((h) => h !== winnerHandle) || "???"; 280 + 281 + // Mark loser dead 282 + const loserPlayer = this.players.get(loser); 283 + if (loserPlayer) loserPlayer.alive = false; 284 + 285 + this.broadcastWS?.("duel:death", { victim: loser, killer: winnerHandle }); 286 + this.broadcastWS?.("duel:roundover", { winner: winnerHandle, loser }); 287 + 288 + console.log(`🎯 Duel round: ${winnerHandle} killed ${loser}`); 289 + } 290 + 291 + advanceStack() { 292 + if (this.roster.length >= 2 && this.roundWinner) { 293 + // Loser goes to bottom 294 + const duelists = this.getDuelists(); 295 + const loserHandle = duelists.find((h) => h !== this.roundWinner); 296 + if (loserHandle) { 297 + this.roster = this.roster.filter((h) => h !== loserHandle); 298 + this.roster.push(loserHandle); 299 + } 300 + } 301 + 302 + this.roundWinner = null; 303 + this.bullets = []; 304 + 305 + this.broadcastWS?.("duel:advance", { roster: this.roster }); 306 + 307 + // Check what to do next 308 + const realPlayers = this.roster.filter((h) => h !== DUMMY_HANDLE); 309 + if (realPlayers.length >= 2) { 310 + this.startCountdown(); 311 + } else if (realPlayers.length === 1) { 312 + // Remove dummy, restart practice 313 + this.roster = this.roster.filter((h) => h !== DUMMY_HANDLE); 314 + this.players.delete(DUMMY_HANDLE); 315 + this.phase = "waiting"; 316 + this.startPractice(realPlayers[0]); 317 + } else { 318 + this.resetToWaiting(); 319 + } 320 + } 321 + 322 + resetToWaiting() { 323 + this.phase = "waiting"; 324 + this.bullets = []; 325 + this.roundWinner = null; 326 + this.countdownTimer = 0; 327 + this.roundOverTimer = 0; 328 + } 329 + 330 + // -- Server Tick -- 331 + 332 + ensureTick() { 333 + if (!this.tickInterval) { 334 + this.tickInterval = setInterval(() => this.serverTick(), 1000 / TICK_RATE); 335 + } 336 + } 337 + 338 + stopTick() { 339 + if (this.tickInterval) { 340 + clearInterval(this.tickInterval); 341 + this.tickInterval = null; 342 + } 343 + this.resetToWaiting(); 344 + } 345 + 346 + serverTick() { 347 + this.tick++; 348 + 349 + if (this.phase === "countdown") { 350 + this.countdownTimer--; 351 + if (this.countdownTimer <= 0) this.startFight(); 352 + } 353 + 354 + if (this.phase === "fight") { 355 + this.tickDummy(); 356 + this.tickMovement(); 357 + this.tickFireOnStop(); 358 + this.tickBullets(); 359 + this.tickHitDetection(); 360 + } 361 + 362 + if (this.phase === "roundover") { 363 + this.roundOverTimer--; 364 + if (this.roundOverTimer <= 0) this.advanceStack(); 365 + } 366 + 367 + // Broadcast snapshot at reduced rate 368 + if (this.tick % SNAPSHOT_INTERVAL === 0) { 369 + this.broadcastSnapshot(); 370 + } 371 + } 372 + 373 + tickDummy() { 374 + const dummy = this.players.get(DUMMY_HANDLE); 375 + if (!dummy || !dummy.alive) return; 376 + 377 + // Wander every ~90 ticks 378 + if (this.tick % 90 === 0) { 379 + dummy.targetX = 20 + Math.random() * (ARENA_W - 40); 380 + dummy.targetY = 20 + Math.random() * (ARENA_H - 40); 381 + } 382 + } 383 + 384 + tickMovement() { 385 + const duelists = this.getDuelists(); 386 + for (const h of duelists) { 387 + const p = this.players.get(h); 388 + if (!p || !p.alive) continue; 389 + 390 + const dx = p.targetX - p.x; 391 + const dy = p.targetY - p.y; 392 + const dist = Math.sqrt(dx * dx + dy * dy); 393 + const isMoving = dist > 2; 394 + 395 + if (isMoving) { 396 + const speed = h === DUMMY_HANDLE ? MOVE_SPEED * 0.7 : MOVE_SPEED; 397 + p.x += (dx / dist) * speed; 398 + p.y += (dy / dist) * speed; 399 + } 400 + 401 + // Track moving state for fire-on-stop 402 + p.wasMoving = isMoving; 403 + } 404 + } 405 + 406 + tickFireOnStop() { 407 + const duelists = this.getDuelists(); 408 + for (const h of duelists) { 409 + if (h === DUMMY_HANDLE) continue; // dummy doesn't fire 410 + const p = this.players.get(h); 411 + if (!p || !p.alive) continue; 412 + 413 + const dx = p.targetX - p.x; 414 + const dy = p.targetY - p.y; 415 + const isMoving = dx * dx + dy * dy > 4; 416 + 417 + // Fire when transitioning from moving to stopped 418 + if (p.wasMoving && !isMoving) { 419 + // Check no bullet already in flight 420 + const hasBullet = this.bullets.some((b) => b.ownerHandle === h); 421 + if (!hasBullet) { 422 + // Find opponent 423 + const opHandle = duelists.find((d) => d !== h); 424 + const op = opHandle ? this.players.get(opHandle) : null; 425 + if (op && op.alive) { 426 + const { nx, ny } = norm(op.x - p.x, op.y - p.y); 427 + this.bullets.push({ 428 + x: p.x + nx * 6, 429 + y: p.y + ny * 6, 430 + vx: nx * BULLET_SPEED, 431 + vy: ny * BULLET_SPEED, 432 + ownerHandle: h, 433 + age: 0, 434 + }); 435 + } 436 + } 437 + } 438 + } 439 + } 440 + 441 + tickBullets() { 442 + for (let i = this.bullets.length - 1; i >= 0; i--) { 443 + const b = this.bullets[i]; 444 + b.x += b.vx; 445 + b.y += b.vy; 446 + b.age++; 447 + 448 + // Remove if off-arena or too old 449 + if ( 450 + b.age > BULLET_MAX_AGE || 451 + b.x < -10 || b.x > ARENA_W + 10 || 452 + b.y < -10 || b.y > ARENA_H + 10 453 + ) { 454 + this.bullets.splice(i, 1); 455 + } 456 + } 457 + } 458 + 459 + tickHitDetection() { 460 + const duelists = this.getDuelists(); 461 + for (let i = this.bullets.length - 1; i >= 0; i--) { 462 + const b = this.bullets[i]; 463 + // Check against non-owner duelist 464 + for (const h of duelists) { 465 + if (h === b.ownerHandle) continue; 466 + const p = this.players.get(h); 467 + if (!p || !p.alive) continue; 468 + 469 + const dx = b.x - p.x; 470 + const dy = b.y - p.y; 471 + if (dx * dx + dy * dy < HIT_R * HIT_R) { 472 + // Hit! Server-authoritative kill 473 + this.bullets.splice(i, 1); 474 + this.endRound(b.ownerHandle); 475 + return; // only one kill per tick 476 + } 477 + } 478 + } 479 + } 480 + 481 + // -- Snapshot Broadcasting -- 482 + 483 + broadcastSnapshot() { 484 + const duelists = this.getDuelists(); 485 + const playersData = duelists.map((h) => { 486 + const p = this.players.get(h); 487 + if (!p) return null; 488 + return { 489 + handle: h, 490 + x: Math.round(p.x * 10) / 10, 491 + y: Math.round(p.y * 10) / 10, 492 + targetX: Math.round(p.targetX * 10) / 10, 493 + targetY: Math.round(p.targetY * 10) / 10, 494 + alive: p.alive, 495 + ping: p.ping, 496 + }; 497 + }).filter(Boolean); 498 + 499 + const bulletsData = this.bullets.map((b) => ({ 500 + x: Math.round(b.x * 10) / 10, 501 + y: Math.round(b.y * 10) / 10, 502 + vx: b.vx, 503 + vy: b.vy, 504 + owner: b.ownerHandle, 505 + age: b.age, 506 + })); 507 + 508 + const lastInputSeq = {}; 509 + for (const h of duelists) { 510 + const p = this.players.get(h); 511 + if (p) lastInputSeq[h] = p.lastInputSeq; 512 + } 513 + 514 + const snapshot = { 515 + tick: this.tick, 516 + phase: this.phase, 517 + countdownTimer: this.countdownTimer, 518 + roundOverTimer: this.roundOverTimer, 519 + roundWinner: this.roundWinner, 520 + players: playersData, 521 + bullets: bulletsData, 522 + roster: this.roster, 523 + lastInputSeq, 524 + }; 525 + 526 + const data = JSON.stringify(snapshot); 527 + 528 + // Send to each player with UDP channel, fallback to WS 529 + for (const [handle, player] of this.players) { 530 + if (handle === DUMMY_HANDLE) continue; 531 + // Try UDP first 532 + if (player.udpChannelId && this.sendUDP) { 533 + this.sendUDP(player.udpChannelId, "duel:snapshot", data); 534 + } else if (player.wsId != null && this.sendWS) { 535 + // Fallback to WS 536 + this.sendWS(player.wsId, "duel:snapshot", snapshot); 537 + } 538 + } 539 + } 540 + }
+370 -310
session-server/session.mjs
··· 73 73 const fairyThrottle = new Map(); // channelId -> last publish timestamp 74 74 const FAIRY_THROTTLE_MS = 100; // 10Hz max per connection 75 75 76 - // Raw UDP fairy relay (for native bare-metal clients) 77 - const udpRelay = dgram.createSocket("udp4"); 78 - const udpClients = new Map(); // key "ip:port" → { address, port, handle, lastSeen } 79 - const UDP_MIDI_SOURCE_TTL_MS = 20000; 80 - const notepatMidiSources = new Map(); // key "@handle:machine" -> source metadata 81 - const notepatMidiSubscribers = new Map(); // connection id -> { ws, all, handle, machineId } 76 + // Raw UDP fairy relay (for native bare-metal clients) 77 + const udpRelay = dgram.createSocket("udp4"); 78 + const udpClients = new Map(); // key "ip:port" → { address, port, handle, lastSeen } 79 + const UDP_MIDI_SOURCE_TTL_MS = 20000; 80 + const notepatMidiSources = new Map(); // key "@handle:machine" -> source metadata 81 + const notepatMidiSubscribers = new Map(); // connection id -> { ws, all, handle, machineId } 82 82 83 83 // Error logging ring buffer (for dashboard display) 84 84 const errorLog = []; ··· 165 165 // Expose the function to chatManager 166 166 chatManager.setPresenceResolver(getHandlesOnPiece); 167 167 168 + // 🎯 Duel Manager — server-authoritative game for dumduel piece 169 + const duelManager = new DuelManager(); 170 + 168 171 import { filter } from "./filter.mjs"; // Profanity filtering. 169 172 import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support. 173 + import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. 170 174 171 175 // *** AC Machines — remote device monitoring *** 172 176 // Devices connect via /machines?role=device&machineId=X&token=Y ··· 2328 2332 return; 2329 2333 } 2330 2334 2331 - if (msg.type === "daw:code") { 2332 - // IDE sending code to all connected devices 2333 - log(`🎹 DAW code broadcast from ${id} to ${dawDevices.size} devices`); 2334 - const codeMsg = JSON.stringify({ 2335 - type: "daw:code", 2335 + if (msg.type === "daw:code") { 2336 + // IDE sending code to all connected devices 2337 + log(`🎹 DAW code broadcast from ${id} to ${dawDevices.size} devices`); 2338 + const codeMsg = JSON.stringify({ 2339 + type: "daw:code", 2336 2340 content: msg.content, 2337 2341 from: id 2338 2342 }); ··· 2344 2348 deviceWs.send(codeMsg); 2345 2349 log(`🎹 Sent code to device ${deviceId}`); 2346 2350 } 2347 - } 2348 - return; 2349 - } 2350 - 2351 - if (msg.type === "notepat:midi:sources") { 2352 - sendNotepatMidiSources(ws); 2353 - return; 2354 - } 2355 - 2356 - if (msg.type === "notepat:midi:subscribe") { 2357 - const filter = msg.content || {}; 2358 - addNotepatMidiSubscriber(id, ws, filter); 2359 - return; 2360 - } 2361 - 2362 - if (msg.type === "notepat:midi:unsubscribe") { 2363 - removeNotepatMidiSubscriber(id); 2364 - if (ws.readyState === WebSocket.OPEN) { 2365 - ws.send(pack("notepat:midi:unsubscribed", true, "midi-relay")); 2366 - } 2367 - return; 2368 - } 2369 - 2370 - msg.id = id; // TODO: When sending a server generated message, use a special id. 2351 + } 2352 + return; 2353 + } 2354 + 2355 + if (msg.type === "notepat:midi:sources") { 2356 + sendNotepatMidiSources(ws); 2357 + return; 2358 + } 2359 + 2360 + if (msg.type === "notepat:midi:subscribe") { 2361 + const filter = msg.content || {}; 2362 + addNotepatMidiSubscriber(id, ws, filter); 2363 + return; 2364 + } 2365 + 2366 + if (msg.type === "notepat:midi:unsubscribe") { 2367 + removeNotepatMidiSubscriber(id); 2368 + if (ws.readyState === WebSocket.OPEN) { 2369 + ws.send(pack("notepat:midi:unsubscribed", true, "midi-relay")); 2370 + } 2371 + return; 2372 + } 2373 + 2374 + msg.id = id; // TODO: When sending a server generated message, use a special id. 2371 2375 2372 2376 // Extract user identity and handle from ANY message that contains it 2373 2377 if (msg.content?.user?.sub) { ··· 2838 2842 log(`🎮 ${msg.type}: ${msg.content?.handle || id} -> all ${wss.clients.size} clients`); 2839 2843 } 2840 2844 2845 + // 🎯 Duel messages — routed to DuelManager (server-authoritative) 2846 + if (msg.type === "duel:join") { 2847 + const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; 2848 + if (handle) duelManager.playerJoin(handle, id); 2849 + return; 2850 + } 2851 + if (msg.type === "duel:leave") { 2852 + const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; 2853 + if (handle) duelManager.playerLeave(handle); 2854 + return; 2855 + } 2856 + if (msg.type === "duel:ping") { 2857 + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2858 + if (parsed?.handle) duelManager.handlePing(parsed.handle, parsed.ts, id); 2859 + return; 2860 + } 2861 + 2841 2862 everyone(JSON.stringify(msg)); // Relay any other message to every user. 2842 2863 } 2843 2864 }); 2844 2865 2845 2866 // More info: https://stackoverflow.com/a/49791634/8146077 2846 - ws.on("close", () => { 2847 - log("🚪 Someone left:", id, "Online:", wss.clients.size, "🫂"); 2848 - const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); 2849 - removeNotepatMidiSubscriber(id); 2850 - 2851 - // Remove from VSCode clients if present 2852 - vscodeClients.delete(ws); 2867 + ws.on("close", () => { 2868 + log("🚪 Someone left:", id, "Online:", wss.clients.size, "🫂"); 2869 + const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); 2870 + if (departingHandle) duelManager.playerLeave(departingHandle); 2871 + removeNotepatMidiSubscriber(id); 2872 + 2873 + // Remove from VSCode clients if present 2874 + vscodeClients.delete(ws); 2853 2875 2854 2876 // Remove from DAW devices if present 2855 2877 if (dawDevices.has(id)) { ··· 3011 3033 connections[connectionId]?.send(msg); 3012 3034 }); 3013 3035 } 3036 + 3037 + // 🎯 Wire DuelManager send functions 3038 + duelManager.setSendFunctions({ 3039 + sendUDP: (channelId, event, data) => { 3040 + const entry = udpChannels[channelId]; 3041 + if (entry?.channel?.webrtcConnection?.state === "open") { 3042 + try { entry.channel.emit(event, data); } catch {} 3043 + } 3044 + }, 3045 + sendWS: (wsId, type, content) => { 3046 + connections[wsId]?.send(pack(type, JSON.stringify(content), "duel")); 3047 + }, 3048 + broadcastWS: (type, content) => { 3049 + everyone(pack(type, JSON.stringify(content), "duel")); 3050 + }, 3051 + resolveUdpForHandle: (handle) => { 3052 + for (const [id, client] of Object.entries(clients)) { 3053 + if (client.handle === handle && udpChannels[id]) return id; 3054 + } 3055 + return null; 3056 + }, 3057 + }); 3014 3058 // #endregion 3015 3059 3016 3060 // *** Status WebSocket Stream *** ··· 3024 3068 // Track VSCode extension clients for direct jump message routing 3025 3069 const vscodeClients = new Set(); 3026 3070 3027 - function normalizeProfileHandle(handle) { 3028 - if (!handle) return null; 3029 - const raw = `${handle}`.trim(); 3030 - if (!raw) return null; 3031 - return `@${raw.replace(/^@+/, "").toLowerCase()}`; 3032 - } 3033 - 3034 - function normalizeMidiHandle(handle) { 3035 - const normalized = normalizeProfileHandle(handle); 3036 - return normalized ? normalized.slice(1) : ""; 3037 - } 3038 - 3039 - function notepatMidiSourceKey(handle, machineId) { 3040 - const handleKey = normalizeProfileHandle(handle) || "@unknown"; 3041 - const machineKey = `${machineId || "unknown"}`.trim() || "unknown"; 3042 - return `${handleKey}:${machineKey}`; 3043 - } 3044 - 3045 - function listNotepatMidiSources() { 3046 - return [...notepatMidiSources.values()] 3047 - .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)) 3048 - .map((source) => ({ 3049 - handle: source.handle || null, 3050 - machineId: source.machineId, 3051 - piece: source.piece || "notepat", 3052 - lastSeen: source.lastSeen || 0, 3053 - lastEvent: source.lastEvent || null, 3054 - })); 3055 - } 3056 - 3057 - function sendNotepatMidiSources(ws) { 3058 - if (!ws || ws.readyState !== WebSocket.OPEN) return; 3059 - try { 3060 - ws.send(pack("notepat:midi:sources", { sources: listNotepatMidiSources() }, "midi-relay")); 3061 - } catch (err) { 3062 - error("🎹 Failed to send notepat midi sources:", err); 3063 - } 3064 - } 3065 - 3066 - function removeNotepatMidiSubscriber(id) { 3067 - if (id === undefined || id === null) return; 3068 - notepatMidiSubscribers.delete(id); 3069 - } 3070 - 3071 - function addNotepatMidiSubscriber(id, ws, filter = {}) { 3072 - if (id === undefined || id === null || !ws) return; 3073 - 3074 - notepatMidiSubscribers.set(id, { 3075 - ws, 3076 - all: filter.all === true, 3077 - handle: normalizeMidiHandle(filter.handle), 3078 - machineId: filter.machineId ? `${filter.machineId}`.trim() : "", 3079 - }); 3080 - 3081 - if (ws.readyState === WebSocket.OPEN) { 3082 - ws.send(pack("notepat:midi:subscribed", { 3083 - all: filter.all === true, 3084 - handle: normalizeMidiHandle(filter.handle) || null, 3085 - machineId: filter.machineId ? `${filter.machineId}`.trim() : null, 3086 - }, "midi-relay")); 3087 - } 3088 - 3089 - sendNotepatMidiSources(ws); 3090 - } 3091 - 3092 - function broadcastNotepatMidiSources() { 3093 - for (const [id, sub] of notepatMidiSubscribers) { 3094 - if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { 3095 - notepatMidiSubscribers.delete(id); 3096 - continue; 3097 - } 3098 - sendNotepatMidiSources(sub.ws); 3099 - } 3100 - } 3101 - 3102 - function notepatMidiSubscriberMatches(sub, event) { 3103 - if (!sub) return false; 3104 - if (sub.all) return true; 3105 - 3106 - const eventHandle = normalizeMidiHandle(event?.handle); 3107 - const eventMachine = event?.machineId ? `${event.machineId}`.trim() : ""; 3108 - 3109 - if (sub.handle && sub.handle !== eventHandle) return false; 3110 - if (sub.machineId && sub.machineId !== eventMachine) return false; 3111 - 3112 - return !!(sub.handle || sub.machineId); 3113 - } 3114 - 3115 - function broadcastNotepatMidiEvent(event) { 3116 - for (const [id, sub] of notepatMidiSubscribers) { 3117 - if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { 3118 - notepatMidiSubscribers.delete(id); 3119 - continue; 3120 - } 3121 - if (!notepatMidiSubscriberMatches(sub, event)) continue; 3122 - try { 3123 - sub.ws.send(pack("notepat:midi", event, "midi-relay")); 3124 - } catch (err) { 3125 - error("🎹 Failed to fan out notepat midi event:", err); 3126 - } 3127 - } 3128 - } 3129 - 3130 - function upsertNotepatMidiSource({ handle, machineId, piece, lastEvent, ts, address, port }) { 3131 - const cleanHandle = normalizeMidiHandle(handle); 3132 - const cleanMachineId = `${machineId || "unknown"}`.trim() || "unknown"; 3133 - const key = notepatMidiSourceKey(cleanHandle, cleanMachineId); 3134 - const previous = notepatMidiSources.get(key); 3135 - const next = { 3136 - handle: cleanHandle || null, 3137 - machineId: cleanMachineId, 3138 - piece: piece || "notepat", 3139 - lastSeen: ts || Date.now(), 3140 - lastEvent: lastEvent || previous?.lastEvent || null, 3141 - address: address || previous?.address || null, 3142 - port: port || previous?.port || null, 3143 - }; 3144 - 3145 - notepatMidiSources.set(key, next); 3146 - 3147 - if (!previous) { 3148 - log(`🎹 Notepat MIDI source online: ${next.handle ? "@" + next.handle : "@unknown"} ${next.machineId}`); 3149 - } 3150 - 3151 - if ( 3152 - !previous || 3153 - previous.handle !== next.handle || 3154 - previous.machineId !== next.machineId || 3155 - previous.piece !== next.piece 3156 - ) { 3157 - broadcastNotepatMidiSources(); 3158 - } 3159 - 3160 - return next; 3161 - } 3071 + function normalizeProfileHandle(handle) { 3072 + if (!handle) return null; 3073 + const raw = `${handle}`.trim(); 3074 + if (!raw) return null; 3075 + return `@${raw.replace(/^@+/, "").toLowerCase()}`; 3076 + } 3077 + 3078 + function normalizeMidiHandle(handle) { 3079 + const normalized = normalizeProfileHandle(handle); 3080 + return normalized ? normalized.slice(1) : ""; 3081 + } 3082 + 3083 + function notepatMidiSourceKey(handle, machineId) { 3084 + const handleKey = normalizeProfileHandle(handle) || "@unknown"; 3085 + const machineKey = `${machineId || "unknown"}`.trim() || "unknown"; 3086 + return `${handleKey}:${machineKey}`; 3087 + } 3088 + 3089 + function listNotepatMidiSources() { 3090 + return [...notepatMidiSources.values()] 3091 + .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)) 3092 + .map((source) => ({ 3093 + handle: source.handle || null, 3094 + machineId: source.machineId, 3095 + piece: source.piece || "notepat", 3096 + lastSeen: source.lastSeen || 0, 3097 + lastEvent: source.lastEvent || null, 3098 + })); 3099 + } 3100 + 3101 + function sendNotepatMidiSources(ws) { 3102 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 3103 + try { 3104 + ws.send(pack("notepat:midi:sources", { sources: listNotepatMidiSources() }, "midi-relay")); 3105 + } catch (err) { 3106 + error("🎹 Failed to send notepat midi sources:", err); 3107 + } 3108 + } 3109 + 3110 + function removeNotepatMidiSubscriber(id) { 3111 + if (id === undefined || id === null) return; 3112 + notepatMidiSubscribers.delete(id); 3113 + } 3114 + 3115 + function addNotepatMidiSubscriber(id, ws, filter = {}) { 3116 + if (id === undefined || id === null || !ws) return; 3117 + 3118 + notepatMidiSubscribers.set(id, { 3119 + ws, 3120 + all: filter.all === true, 3121 + handle: normalizeMidiHandle(filter.handle), 3122 + machineId: filter.machineId ? `${filter.machineId}`.trim() : "", 3123 + }); 3124 + 3125 + if (ws.readyState === WebSocket.OPEN) { 3126 + ws.send(pack("notepat:midi:subscribed", { 3127 + all: filter.all === true, 3128 + handle: normalizeMidiHandle(filter.handle) || null, 3129 + machineId: filter.machineId ? `${filter.machineId}`.trim() : null, 3130 + }, "midi-relay")); 3131 + } 3132 + 3133 + sendNotepatMidiSources(ws); 3134 + } 3135 + 3136 + function broadcastNotepatMidiSources() { 3137 + for (const [id, sub] of notepatMidiSubscribers) { 3138 + if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { 3139 + notepatMidiSubscribers.delete(id); 3140 + continue; 3141 + } 3142 + sendNotepatMidiSources(sub.ws); 3143 + } 3144 + } 3145 + 3146 + function notepatMidiSubscriberMatches(sub, event) { 3147 + if (!sub) return false; 3148 + if (sub.all) return true; 3149 + 3150 + const eventHandle = normalizeMidiHandle(event?.handle); 3151 + const eventMachine = event?.machineId ? `${event.machineId}`.trim() : ""; 3152 + 3153 + if (sub.handle && sub.handle !== eventHandle) return false; 3154 + if (sub.machineId && sub.machineId !== eventMachine) return false; 3155 + 3156 + return !!(sub.handle || sub.machineId); 3157 + } 3158 + 3159 + function broadcastNotepatMidiEvent(event) { 3160 + for (const [id, sub] of notepatMidiSubscribers) { 3161 + if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { 3162 + notepatMidiSubscribers.delete(id); 3163 + continue; 3164 + } 3165 + if (!notepatMidiSubscriberMatches(sub, event)) continue; 3166 + try { 3167 + sub.ws.send(pack("notepat:midi", event, "midi-relay")); 3168 + } catch (err) { 3169 + error("🎹 Failed to fan out notepat midi event:", err); 3170 + } 3171 + } 3172 + } 3173 + 3174 + function upsertNotepatMidiSource({ handle, machineId, piece, lastEvent, ts, address, port }) { 3175 + const cleanHandle = normalizeMidiHandle(handle); 3176 + const cleanMachineId = `${machineId || "unknown"}`.trim() || "unknown"; 3177 + const key = notepatMidiSourceKey(cleanHandle, cleanMachineId); 3178 + const previous = notepatMidiSources.get(key); 3179 + const next = { 3180 + handle: cleanHandle || null, 3181 + machineId: cleanMachineId, 3182 + piece: piece || "notepat", 3183 + lastSeen: ts || Date.now(), 3184 + lastEvent: lastEvent || previous?.lastEvent || null, 3185 + address: address || previous?.address || null, 3186 + port: port || previous?.port || null, 3187 + }; 3188 + 3189 + notepatMidiSources.set(key, next); 3190 + 3191 + if (!previous) { 3192 + log(`🎹 Notepat MIDI source online: ${next.handle ? "@" + next.handle : "@unknown"} ${next.machineId}`); 3193 + } 3194 + 3195 + if ( 3196 + !previous || 3197 + previous.handle !== next.handle || 3198 + previous.machineId !== next.machineId || 3199 + previous.piece !== next.piece 3200 + ) { 3201 + broadcastNotepatMidiSources(); 3202 + } 3203 + 3204 + return next; 3205 + } 3162 3206 3163 3207 function compactProfileText(value) { 3164 3208 return `${value || ""}`.replace(/\s+/g, " ").trim(); ··· 3393 3437 connectedAt: Date.now(), 3394 3438 state: channel.webrtcConnection.state, 3395 3439 user: null, 3396 - handle: null 3440 + handle: null, 3441 + channel: channel, // Store reference for targeted sends 3397 3442 }; 3398 3443 3399 3444 // Get IP address from channel ··· 3447 3492 if (identity.handle) { 3448 3493 clients[channel.id].handle = identity.handle; 3449 3494 log(`✅ UDP ${channel.id} handle: "${identity.handle}"`); 3495 + // Resolve UDP channel for duel if this handle is in a duel 3496 + duelManager.resolveUdpChannel(identity.handle, channel.id); 3450 3497 } 3451 3498 } catch (e) { 3452 3499 error(`🩰 Failed to parse identity for ${channel.id}:`, e); ··· 3556 3603 } 3557 3604 }); 3558 3605 3606 + // 🎯 Duel input over UDP (server-authoritative — NOT relayed, fed to DuelManager) 3607 + channel.on("duel:input", (data) => { 3608 + if (channel.webrtcConnection.state === "open") { 3609 + try { 3610 + const parsed = typeof data === "string" ? JSON.parse(data) : data; 3611 + const handle = clients[channel.id]?.handle; 3612 + if (handle) duelManager.receiveInput(handle, parsed); 3613 + } catch (err) { 3614 + console.warn("duel:input error:", err); 3615 + } 3616 + } 3617 + }); 3618 + 3559 3619 // 🎚️ Slide mode: real-time code value updates via UDP (lowest latency) 3560 3620 channel.on("slide:code", (data) => { 3561 3621 if (channel.webrtcConnection.state === "open") { ··· 3582 3642 3583 3643 // #endregion 3584 3644 3585 - // --------------------------------------------------------------------------- 3586 - // 🧚 Raw UDP fairy relay (port 10010) — for native bare-metal clients 3587 - // Binary packet format: 3588 - // [1 byte type] [4 float x LE] [4 float y LE] [1 handle_len] [N handle] 3589 - // Type 0x01 = client→server, 0x02 = server→client broadcast 3590 - // --------------------------------------------------------------------------- 3591 - const UDP_FAIRY_PORT = 10010; 3592 - 3593 - function handleNotepatMidiUdpPacket(payload, rinfo) { 3594 - if (!payload || (payload.type !== "notepat:midi" && payload.type !== "notepat:midi:heartbeat")) { 3595 - return false; 3596 - } 3597 - 3598 - const now = Date.now(); 3599 - const source = upsertNotepatMidiSource({ 3600 - handle: payload.handle, 3601 - machineId: payload.machineId, 3602 - piece: payload.piece || "notepat", 3603 - lastEvent: payload.type === "notepat:midi" ? payload.event : "heartbeat", 3604 - ts: now, 3605 - address: rinfo.address, 3606 - port: rinfo.port, 3607 - }); 3608 - 3609 - if (!source.handle && !source.machineId) { 3610 - return true; 3611 - } 3612 - 3613 - if (payload.type === "notepat:midi:heartbeat") { 3614 - return true; 3615 - } 3616 - 3617 - const rawNote = Number(payload.note); 3618 - const rawVelocity = Number(payload.velocity); 3619 - const rawChannel = Number(payload.channel); 3620 - if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) { 3621 - log("🎹 Invalid notepat midi UDP payload:", payload); 3622 - return true; 3623 - } 3624 - 3625 - let event = payload.event === "note_off" ? "note_off" : "note_on"; 3626 - const note = Math.max(0, Math.min(127, Math.round(rawNote))); 3627 - const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); 3628 - const channel = Math.max(0, Math.min(15, Math.round(rawChannel))); 3629 - if (event === "note_on" && velocity === 0) event = "note_off"; 3630 - 3631 - broadcastNotepatMidiEvent({ 3632 - type: "notepat:midi", 3633 - event, 3634 - note, 3635 - velocity, 3636 - channel, 3637 - handle: source.handle, 3638 - machineId: source.machineId, 3639 - piece: source.piece || "notepat", 3640 - ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, 3641 - }); 3642 - 3643 - return true; 3644 - } 3645 - 3646 - function pruneNotepatMidiSources() { 3647 - const now = Date.now(); 3648 - let changed = false; 3649 - 3650 - for (const [key, source] of notepatMidiSources) { 3651 - if (now - (source.lastSeen || 0) > UDP_MIDI_SOURCE_TTL_MS) { 3652 - notepatMidiSources.delete(key); 3653 - changed = true; 3654 - } 3655 - } 3656 - 3657 - if (changed) broadcastNotepatMidiSources(); 3658 - } 3659 - 3660 - udpRelay.on("message", (msg, rinfo) => { 3661 - if (msg.length > 0 && msg[0] === 0x01 && msg.length >= 10) { 3662 - const key = `${rinfo.address}:${rinfo.port}`; 3663 - const x = msg.readFloatLE(1); 3664 - const y = msg.readFloatLE(5); 3665 - const hlen = msg[9]; 3666 - const handle = msg.slice(10, 10 + hlen).toString("utf8"); 3667 - 3668 - udpClients.set(key, { address: rinfo.address, port: rinfo.port, handle, lastSeen: Date.now() }); 3669 - 3670 - // Build broadcast packet (type 0x02) 3671 - const bcast = Buffer.alloc(msg.length); 3672 - msg.copy(bcast); 3673 - bcast[0] = 0x02; 3674 - 3675 - // Broadcast to all other UDP clients 3676 - for (const [k, client] of udpClients) { 3677 - if (k !== key) { 3678 - udpRelay.send(bcast, client.port, client.address); 3679 - } 3680 - } 3681 - 3682 - // Also broadcast to Geckos.io WebRTC clients as fairy:point 3683 - const fairyData = JSON.stringify({ x, y, handle }); 3684 - try { 3685 - // Emit to all geckos channels 3686 - io.room().emit("fairy:point", fairyData); 3687 - } catch (e) { /* ignore */ } 3688 - 3689 - // Publish to Redis for silo firehose (throttled) 3690 - const now = Date.now(); 3691 - const lastFairy = fairyThrottle.get(key) || 0; 3692 - if (now - lastFairy >= FAIRY_THROTTLE_MS) { 3693 - fairyThrottle.set(key, now); 3694 - pub.publish("fairy:point", fairyData).catch(() => {}); 3695 - } 3696 - return; 3697 - } 3698 - 3699 - if (msg.length > 0 && msg[0] === 0x7b) { 3700 - try { 3701 - const payload = JSON.parse(msg.toString("utf8")); 3702 - if (handleNotepatMidiUdpPacket(payload, rinfo)) return; 3703 - } catch (err) { 3704 - log("🎹 Failed to parse UDP JSON packet:", err?.message || err); 3705 - } 3706 - } 3707 - }); 3708 - 3709 - // Clean up stale UDP clients every 30s 3710 - setInterval(() => { 3711 - const now = Date.now(); 3712 - for (const [key, client] of udpClients) { 3713 - if (now - client.lastSeen > 30000) udpClients.delete(key); 3714 - } 3715 - pruneNotepatMidiSources(); 3716 - }, 30000); 3645 + // --------------------------------------------------------------------------- 3646 + // 🧚 Raw UDP fairy relay (port 10010) — for native bare-metal clients 3647 + // Binary packet format: 3648 + // [1 byte type] [4 float x LE] [4 float y LE] [1 handle_len] [N handle] 3649 + // Type 0x01 = client→server, 0x02 = server→client broadcast 3650 + // --------------------------------------------------------------------------- 3651 + const UDP_FAIRY_PORT = 10010; 3652 + 3653 + function handleNotepatMidiUdpPacket(payload, rinfo) { 3654 + if (!payload || (payload.type !== "notepat:midi" && payload.type !== "notepat:midi:heartbeat")) { 3655 + return false; 3656 + } 3657 + 3658 + const now = Date.now(); 3659 + const source = upsertNotepatMidiSource({ 3660 + handle: payload.handle, 3661 + machineId: payload.machineId, 3662 + piece: payload.piece || "notepat", 3663 + lastEvent: payload.type === "notepat:midi" ? payload.event : "heartbeat", 3664 + ts: now, 3665 + address: rinfo.address, 3666 + port: rinfo.port, 3667 + }); 3668 + 3669 + if (!source.handle && !source.machineId) { 3670 + return true; 3671 + } 3672 + 3673 + if (payload.type === "notepat:midi:heartbeat") { 3674 + return true; 3675 + } 3676 + 3677 + const rawNote = Number(payload.note); 3678 + const rawVelocity = Number(payload.velocity); 3679 + const rawChannel = Number(payload.channel); 3680 + if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) { 3681 + log("🎹 Invalid notepat midi UDP payload:", payload); 3682 + return true; 3683 + } 3684 + 3685 + let event = payload.event === "note_off" ? "note_off" : "note_on"; 3686 + const note = Math.max(0, Math.min(127, Math.round(rawNote))); 3687 + const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); 3688 + const channel = Math.max(0, Math.min(15, Math.round(rawChannel))); 3689 + if (event === "note_on" && velocity === 0) event = "note_off"; 3690 + 3691 + broadcastNotepatMidiEvent({ 3692 + type: "notepat:midi", 3693 + event, 3694 + note, 3695 + velocity, 3696 + channel, 3697 + handle: source.handle, 3698 + machineId: source.machineId, 3699 + piece: source.piece || "notepat", 3700 + ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, 3701 + }); 3702 + 3703 + return true; 3704 + } 3705 + 3706 + function pruneNotepatMidiSources() { 3707 + const now = Date.now(); 3708 + let changed = false; 3709 + 3710 + for (const [key, source] of notepatMidiSources) { 3711 + if (now - (source.lastSeen || 0) > UDP_MIDI_SOURCE_TTL_MS) { 3712 + notepatMidiSources.delete(key); 3713 + changed = true; 3714 + } 3715 + } 3716 + 3717 + if (changed) broadcastNotepatMidiSources(); 3718 + } 3719 + 3720 + udpRelay.on("message", (msg, rinfo) => { 3721 + if (msg.length > 0 && msg[0] === 0x01 && msg.length >= 10) { 3722 + const key = `${rinfo.address}:${rinfo.port}`; 3723 + const x = msg.readFloatLE(1); 3724 + const y = msg.readFloatLE(5); 3725 + const hlen = msg[9]; 3726 + const handle = msg.slice(10, 10 + hlen).toString("utf8"); 3727 + 3728 + udpClients.set(key, { address: rinfo.address, port: rinfo.port, handle, lastSeen: Date.now() }); 3729 + 3730 + // Build broadcast packet (type 0x02) 3731 + const bcast = Buffer.alloc(msg.length); 3732 + msg.copy(bcast); 3733 + bcast[0] = 0x02; 3734 + 3735 + // Broadcast to all other UDP clients 3736 + for (const [k, client] of udpClients) { 3737 + if (k !== key) { 3738 + udpRelay.send(bcast, client.port, client.address); 3739 + } 3740 + } 3741 + 3742 + // Also broadcast to Geckos.io WebRTC clients as fairy:point 3743 + const fairyData = JSON.stringify({ x, y, handle }); 3744 + try { 3745 + // Emit to all geckos channels 3746 + io.room().emit("fairy:point", fairyData); 3747 + } catch (e) { /* ignore */ } 3748 + 3749 + // Publish to Redis for silo firehose (throttled) 3750 + const now = Date.now(); 3751 + const lastFairy = fairyThrottle.get(key) || 0; 3752 + if (now - lastFairy >= FAIRY_THROTTLE_MS) { 3753 + fairyThrottle.set(key, now); 3754 + pub.publish("fairy:point", fairyData).catch(() => {}); 3755 + } 3756 + return; 3757 + } 3758 + 3759 + if (msg.length > 0 && msg[0] === 0x7b) { 3760 + try { 3761 + const payload = JSON.parse(msg.toString("utf8")); 3762 + if (handleNotepatMidiUdpPacket(payload, rinfo)) return; 3763 + } catch (err) { 3764 + log("🎹 Failed to parse UDP JSON packet:", err?.message || err); 3765 + } 3766 + } 3767 + }); 3768 + 3769 + // Clean up stale UDP clients every 30s 3770 + setInterval(() => { 3771 + const now = Date.now(); 3772 + for (const [key, client] of udpClients) { 3773 + if (now - client.lastSeen > 30000) udpClients.delete(key); 3774 + } 3775 + pruneNotepatMidiSources(); 3776 + }, 30000); 3717 3777 3718 3778 udpRelay.bind(UDP_FAIRY_PORT, () => { 3719 3779 console.log(`🧚 Raw UDP fairy relay listening on port ${UDP_FAIRY_PORT}`);
+168 -365
system/public/aesthetic.computer/disks/dumduel.mjs
··· 1 1 // Dumduel, 2026.03.30 2 - // Top-down stick figure shootout. Tap to run, dodge bullets, instant death. 3 - // Two duelists at a time — everyone else waits in the stack. 2 + // Top-down stick figure shootout — server-authoritative netcode. 3 + // Client sends inputs, server owns state, snapshots broadcast via UDP. 4 4 5 5 const ARENA_W = 220; 6 6 const ARENA_H = 220; 7 - const BULLET_SPEED = 0.7; 7 + const MOVE_SPEED = 1.0; 8 8 const BULLET_R = 2; 9 - const MOVE_SPEED = 1.0; 10 - const COUNTDOWN_FRAMES = 180; 11 - const ROUND_OVER_FRAMES = 120; 12 - const HIT_R = 7; 13 9 const BODY_R = 4; 14 10 15 11 // -- State -- 16 12 let server, udpChannel; 17 13 let myHandle = "guest"; 18 - let myId = null; 14 + let sendFn = null; 15 + let synth = null; 19 16 let sw = 0, sh = 0; 20 17 let frameCount = 0; 21 - let synth = null; // sound.synth reference 22 - let sendFn = null; // send() for window:reload 23 - let updateAvailable = false; 24 18 25 - let roster = []; // [{ id, handle }] — first two duel, rest wait 26 - let phase = "waiting"; // waiting | countdown | fight | roundover 19 + // Input prediction 20 + let inputSeq = 0; 21 + let pendingInputs = []; // { seq, targetX, targetY } 22 + let localX = 0, localY = 0; 23 + let localTargetX = 0, localTargetY = 0; 24 + let localWasMoving = false; 25 + 26 + // Server state (from snapshots) 27 + let snap = null; // latest snapshot 28 + let roster = []; 29 + let phase = "waiting"; 27 30 let countdownTimer = 0; 28 - let roundOverTimer = 0; 29 31 let roundWinner = null; 30 - 31 - let me = null; // { x, y, targetX, targetY, alive, wasMoving } 32 - let opponent = null; // { x, y, targetX, targetY, alive, handle } 33 - let bullets = []; // { x, y, vx, vy, owner: "me"|"them" } 34 - let mySlot = -1; 35 - let dummy = false; 36 - 37 - function isDueling() { 38 - return roster.length >= 2 && 39 - (roster[0].handle === myHandle || roster[1].handle === myHandle); 40 - } 41 - 42 - function myRosterIdx() { 43 - return roster.findIndex((r) => r.handle === myHandle); 44 - } 45 - 46 - function spawnDuelists() { 47 - bullets = []; 48 - // Spawn in opposite corners 49 - const pos0 = { x: 30, y: 30 }; 50 - const pos1 = { x: ARENA_W - 30, y: ARENA_H - 30 }; 51 - const myPos = mySlot === 0 ? pos0 : pos1; 52 - const opPos = mySlot === 0 ? pos1 : pos0; 53 - 54 - me = { 55 - x: myPos.x, y: myPos.y, 56 - targetX: myPos.x, targetY: myPos.y, 57 - alive: true, wasMoving: false, 58 - }; 59 - // Find opponent handle from roster (whoever isn't me among first two) 60 - const opHandle = roster.length >= 2 61 - ? (roster[0].handle === myHandle ? roster[1].handle : roster[0].handle) 62 - : "???"; 63 - opponent = { 64 - x: opPos.x, y: opPos.y, 65 - targetX: opPos.x, targetY: opPos.y, 66 - alive: true, 67 - handle: opHandle, 68 - }; 69 - } 70 - 71 - function startPractice() { 72 - dummy = true; 73 - if (!roster.find((r) => r.handle === "dummy")) { 74 - roster.push({ id: "dummy", handle: "dummy" }); 75 - } 76 - mySlot = 0; 77 - phase = "countdown"; 78 - countdownTimer = COUNTDOWN_FRAMES; 79 - spawnDuelists(); 80 - if (opponent) opponent.handle = "dummy"; 81 - } 82 - 83 - function stopPractice() { 84 - dummy = false; 85 - roster = roster.filter((r) => r.handle !== "dummy"); 86 - me = null; opponent = null; bullets = []; 87 - } 32 + let ping = 0; 88 33 89 - function startCountdown() { 90 - phase = "countdown"; 91 - countdownTimer = COUNTDOWN_FRAMES; 92 - 93 - // Deterministic slot: lower handle alphabetically = slot 0 94 - if (roster.length >= 2) { 95 - const h0 = roster[0].handle; 96 - const h1 = roster[1].handle; 97 - if (myHandle === h0) mySlot = h0 < h1 ? 0 : 1; 98 - else if (myHandle === h1) mySlot = h1 < h0 ? 0 : 1; 99 - else mySlot = -1; // spectating 100 - } else { 101 - mySlot = -1; 102 - } 103 - 104 - if (isDueling()) spawnDuelists(); 105 - } 106 - 107 - function startFight() { 108 - phase = "fight"; 109 - if (isDueling() && !me) spawnDuelists(); 110 - // Fight start sound — short rising tone 111 - synth?.({ type: "square", tone: 440, volume: 0.7, attack: 0.01, decay: 0.15, duration: 0.2 }); 112 - } 113 - 114 - function endRound(winnerHandle) { 115 - roundWinner = winnerHandle; 116 - phase = "roundover"; 117 - roundOverTimer = ROUND_OVER_FRAMES; 118 - // Death sound 119 - const won = winnerHandle === myHandle; 120 - if (won) { 121 - synth?.({ type: "triangle", tone: 660, volume: 0.8, attack: 0.01, decay: 0.3, duration: 0.35 }); 122 - } else { 123 - synth?.({ type: "sawtooth", tone: 120, volume: 0.6, attack: 0.01, decay: 0.4, duration: 0.5 }); 124 - } 125 - } 126 - 127 - function advanceStack() { 128 - if (roster.length >= 2) { 129 - const loserIdx = roster[0].handle === roundWinner ? 1 : 0; 130 - const loser = roster.splice(loserIdx, 1)[0]; 131 - roster.push(loser); 132 - } 133 - roundWinner = null; 134 - me = null; opponent = null; bullets = []; 135 - if (roster.length >= 2) startCountdown(); 136 - else startPractice(); 137 - } 138 - 139 - // Normalize a 2D vector 140 - function norm(dx, dy) { 141 - const len = Math.sqrt(dx * dx + dy * dy); 142 - if (len < 0.001) return { nx: 0, ny: 0 }; 143 - return { nx: dx / len, ny: dy / len }; 144 - } 145 - 146 - function boot({ wipe, screen, net: { socket, udp }, handle, sound, send, net }) { 34 + function boot({ wipe, screen, net: { socket, udp }, handle, sound, send }) { 147 35 sw = screen.width; 148 36 sh = screen.height; 149 37 myHandle = handle?.() || "guest_" + Math.floor(Math.random() * 9999); ··· 151 39 sendFn = send; 152 40 153 41 // Version polling — auto-reload on new deploy 154 - const pollVersion = async () => { 42 + (async () => { 155 43 try { 156 44 const res = await fetch("/api/version"); 157 45 if (!res.ok) return; 158 46 const info = await res.json(); 159 47 const current = info.deployed; 160 - // Long-poll for changes 161 48 while (true) { 162 49 try { 163 50 const r = await fetch(`/api/version?current=${current}`); 164 51 if (!r.ok) break; 165 52 const data = await r.json(); 166 - if (data.changed !== false) { 167 - // Auto-reload on new deploy 168 - sendFn?.({ type: "window:reload" }); 169 - break; 170 - } 53 + if (data.changed !== false) { sendFn?.({ type: "window:reload" }); break; } 171 54 } catch { break; } 172 55 } 173 56 } catch {} 174 - }; 175 - pollVersion(); 57 + })(); 58 + 59 + // Ping measurement every 2s 60 + setInterval(() => { 61 + server?.send("duel:ping", { handle: myHandle, ts: Date.now() }); 62 + }, 2000); 176 63 64 + // UDP — receive snapshots from server 177 65 udpChannel = udp((type, content) => { 178 - const d = typeof content === "string" ? JSON.parse(content) : content; 179 - if (d.handle === myHandle) return; 180 - if (type === "duel:pos" && opponent) { 181 - opponent.x = d.x; opponent.y = d.y; 182 - opponent.targetX = d.tx; opponent.targetY = d.ty; 66 + if (type === "duel:snapshot") { 67 + const s = typeof content === "string" ? JSON.parse(content) : content; 68 + snap = s; 69 + phase = s.phase; 70 + countdownTimer = s.countdownTimer; 71 + roundWinner = s.roundWinner; 72 + roster = (s.roster || []).map((h) => ({ handle: h })); 73 + 74 + // Reconcile prediction 75 + const myAck = s.lastInputSeq?.[myHandle] || 0; 76 + pendingInputs = pendingInputs.filter((inp) => inp.seq > myAck); 77 + 78 + const meServer = s.players?.find((p) => p.handle === myHandle); 79 + if (meServer) { 80 + // Start from server position 81 + localX = meServer.x; 82 + localY = meServer.y; 83 + localTargetX = meServer.targetX; 84 + localTargetY = meServer.targetY; 85 + 86 + // Re-apply unacknowledged inputs 87 + for (const inp of pendingInputs) { 88 + localTargetX = inp.targetX; 89 + localTargetY = inp.targetY; 90 + } 91 + 92 + ping = meServer.ping || 0; 93 + } 183 94 } 184 95 }); 185 96 97 + // WebSocket — reliable game events 186 98 server = socket((id, type, content) => { 187 99 if (type.startsWith("connected")) { 188 - myId = id; 189 100 server.send("duel:join", { handle: myHandle }); 190 101 return; 191 102 } 192 103 193 - if (type === "left") { 194 - const idx = roster.findIndex((r) => r.id === id); 195 - if (idx >= 0) { 196 - const wasDueling = idx < 2 && roster.length >= 2; 197 - roster.splice(idx, 1); 198 - if (wasDueling && (phase === "fight" || phase === "countdown")) { 199 - if (isDueling()) endRound(myHandle); 200 - else { phase = "waiting"; me = null; opponent = null; bullets = []; } 201 - } 202 - } 203 - return; 204 - } 205 - 206 104 const msg = typeof content === "string" ? JSON.parse(content) : content; 207 105 208 - if (type === "duel:join" && msg.handle !== myHandle) { 209 - if (dummy) stopPractice(); 210 - // Update ID if handle already exists (reconnect), otherwise add 211 - const existing = roster.find((r) => r.handle === msg.handle); 212 - if (existing) { 213 - existing.id = id; 214 - } else { 215 - roster.push({ id, handle: msg.handle }); 216 - } 217 - server.send("duel:roster", { 218 - handle: myHandle, 219 - roster: roster.map((r) => ({ handle: r.handle })), 220 - phase, 221 - }); 222 - if (roster.length >= 2 && phase === "waiting") { 223 - startCountdown(); 224 - server.send("duel:countdown", { handle: myHandle }); 225 - } 106 + if (type === "duel:joined" || type === "duel:roster") { 107 + roster = (msg.roster || []).map((h) => ({ handle: h })); 108 + if (msg.phase) phase = msg.phase; 226 109 } 227 110 228 - if (type === "duel:roster" && msg.handle !== myHandle) { 229 - if (roster.length <= 1) { 230 - for (const r of msg.roster) { 231 - if (!roster.find((x) => x.handle === r.handle)) { 232 - roster.push({ id: r.handle === msg.handle ? id : "?", handle: r.handle }); 233 - } 234 - } 235 - if (msg.phase === "countdown" || msg.phase === "fight") startCountdown(); 236 - } 111 + if (type === "duel:countdown") { 112 + phase = "countdown"; 113 + countdownTimer = msg.timer || 180; 237 114 } 238 115 239 - if (type === "duel:countdown" && msg.handle !== myHandle) { 240 - if (roster.length >= 2 && phase === "waiting") startCountdown(); 116 + if (type === "duel:fight") { 117 + phase = "fight"; 118 + synth?.({ type: "square", tone: 440, volume: 0.7, attack: 0.01, decay: 0.15, duration: 0.2 }); 241 119 } 242 120 243 - if (type === "duel:fire" && phase === "fight" && msg.handle !== myHandle) { 244 - bullets.push({ x: msg.x, y: msg.y, vx: msg.vx, vy: msg.vy, owner: "them" }); 121 + if (type === "duel:death") { 122 + const won = msg.killer === myHandle; 123 + if (won) { 124 + synth?.({ type: "triangle", tone: 660, volume: 0.8, attack: 0.01, decay: 0.3, duration: 0.35 }); 125 + } else if (msg.victim === myHandle) { 126 + synth?.({ type: "sawtooth", tone: 120, volume: 0.6, attack: 0.01, decay: 0.4, duration: 0.5 }); 127 + } 245 128 } 246 129 247 - if (type === "duel:hit" && phase === "fight" && msg.handle !== myHandle) { 248 - if (msg.victim === myHandle) { 249 - if (me) me.alive = false; 250 - endRound(msg.handle); 251 - } 130 + if (type === "duel:roundover") { 131 + phase = "roundover"; 132 + roundWinner = msg.winner; 252 133 } 253 134 254 - if (type === "duel:roundover" && phase === "fight" && msg.handle !== myHandle) { 255 - endRound(msg.winner); 256 - if (opponent && msg.winner === myHandle) opponent.alive = false; 257 - else if (me && msg.winner !== myHandle) me.alive = false; 135 + if (type === "duel:advance") { 136 + roster = (msg.roster || []).map((h) => ({ handle: h })); 137 + roundWinner = null; 258 138 } 259 139 260 - if (type === "duel:advance" && msg.handle !== myHandle) advanceStack(); 140 + if (type === "duel:pong") { 141 + ping = Date.now() - msg.ts; 142 + } 261 143 }); 262 144 263 - roster.push({ id: myId, handle: myHandle }); 264 - startPractice(); 265 145 wipe(240, 238, 232); 266 146 } 267 147 268 148 function sim() { 269 149 frameCount++; 270 150 271 - if (phase === "countdown") { 272 - countdownTimer--; 273 - // Tick each second 274 - if (countdownTimer > 0 && countdownTimer % 60 === 0) { 275 - synth?.({ type: "sine", tone: 330, volume: 0.5, attack: 0.005, decay: 0.1, duration: 0.12 }); 276 - } 277 - if (countdownTimer <= 0) startFight(); 278 - } 279 - 280 - if (phase === "roundover") { 281 - roundOverTimer--; 282 - if (roundOverTimer <= 0) { 283 - advanceStack(); 284 - server?.send("duel:advance", { handle: myHandle }); 285 - } 286 - } 287 - 288 - if (phase !== "fight" || !me || (!isDueling() && !dummy)) return; 289 - 290 - // Dummy AI — just wander (no shooting) 291 - if (dummy && opponent?.alive) { 292 - if (frameCount % 90 === 0) { 293 - opponent.targetX = 20 + Math.random() * (ARENA_W - 40); 294 - opponent.targetY = 20 + Math.random() * (ARENA_H - 40); 295 - } 296 - const odx = opponent.targetX - opponent.x; 297 - const ody = opponent.targetY - opponent.y; 298 - const dist = Math.sqrt(odx * odx + ody * ody); 299 - if (dist > 1) { 300 - opponent.x += (odx / dist) * MOVE_SPEED * 0.7; 301 - opponent.y += (ody / dist) * MOVE_SPEED * 0.7; 302 - } 303 - } 304 - 305 - // Move me toward target 306 - if (me.alive) { 307 - const dx = me.targetX - me.x; 308 - const dy = me.targetY - me.y; 151 + // Predict local movement 152 + if (phase === "fight") { 153 + const dx = localTargetX - localX; 154 + const dy = localTargetY - localY; 309 155 const dist = Math.sqrt(dx * dx + dy * dy); 310 156 const isMoving = dist > 2; 311 157 if (isMoving) { 312 - me.x += (dx / dist) * MOVE_SPEED; 313 - me.y += (dy / dist) * MOVE_SPEED; 158 + localX += (dx / dist) * MOVE_SPEED; 159 + localY += (dy / dist) * MOVE_SPEED; 314 160 } 315 161 316 - // Fire on stop (was moving, now stopped, no bullet in flight) 317 - const myBulletOut = bullets.some((b) => b.owner === "me"); 318 - if (me.wasMoving && !isMoving && !myBulletOut && opponent) { 319 - const { nx, ny } = norm(opponent.x - me.x, opponent.y - me.y); 320 - const bx = me.x + nx * 6; 321 - const by = me.y + ny * 6; 322 - bullets.push({ x: bx, y: by, vx: nx * BULLET_SPEED, vy: ny * BULLET_SPEED, owner: "me" }); 323 - synth?.({ type: "square", tone: 800, volume: 0.35, attack: 0.001, decay: 0.06, duration: 0.07 }); 324 - server?.send("duel:fire", { 325 - handle: myHandle, x: bx, y: by, vx: nx * BULLET_SPEED, vy: ny * BULLET_SPEED, 326 - }); 327 - } 328 - me.wasMoving = isMoving; 329 - } 330 - 331 - // Interpolate opponent (non-dummy) 332 - if (!dummy && opponent) { 333 - const dx = opponent.targetX - opponent.x; 334 - const dy = opponent.targetY - opponent.y; 335 - const dist = Math.sqrt(dx * dx + dy * dy); 336 - if (dist > 1) { 337 - opponent.x += (dx / dist) * MOVE_SPEED; 338 - opponent.y += (dy / dist) * MOVE_SPEED; 339 - } 340 - } 341 - 342 - // Update bullets 343 - for (let i = bullets.length - 1; i >= 0; i--) { 344 - const b = bullets[i]; 345 - b.x += b.vx; 346 - b.y += b.vy; 347 - b.age = (b.age || 0) + 1; 348 - 349 - // Fade out after traveling a while 350 - if (b.age > 200) { 351 - bullets.splice(i, 1); 352 - continue; 353 - } 354 - 355 - // Off arena 356 - if (b.x < -10 || b.x > ARENA_W + 10 || b.y < -10 || b.y > ARENA_H + 10) { 357 - bullets.splice(i, 1); 358 - continue; 359 - } 360 - 361 - // Hit detection 362 - if (b.owner === "them" && me.alive) { 363 - const dx = b.x - me.x, dy = b.y - me.y; 364 - if (dx * dx + dy * dy < HIT_R * HIT_R) { 365 - me.alive = false; 366 - bullets.splice(i, 1); 367 - endRound(opponent?.handle || "???"); 368 - server?.send("duel:roundover", { handle: myHandle, winner: opponent?.handle || "???" }); 369 - break; 162 + // Optimistic fire sound (predict when we stop) 163 + if (localWasMoving && !isMoving && snap) { 164 + const myBulletOut = snap.bullets?.some((b) => b.owner === myHandle); 165 + if (!myBulletOut) { 166 + synth?.({ type: "square", tone: 800, volume: 0.35, attack: 0.001, decay: 0.06, duration: 0.07 }); 370 167 } 371 168 } 372 - if (b.owner === "me" && opponent?.alive) { 373 - const dx = b.x - opponent.x, dy = b.y - opponent.y; 374 - if (dx * dx + dy * dy < HIT_R * HIT_R) { 375 - opponent.alive = false; 376 - bullets.splice(i, 1); 377 - endRound(myHandle); 378 - server?.send("duel:hit", { handle: myHandle, victim: opponent.handle }); 379 - server?.send("duel:roundover", { handle: myHandle, winner: myHandle }); 380 - break; 381 - } 382 - } 383 - } 384 - 385 - // Send position via UDP every 3 frames 386 - if (frameCount % 3 === 0 && udpChannel?.connected && me.alive) { 387 - udpChannel.send("duel:pos", { 388 - handle: myHandle, x: me.x, y: me.y, tx: me.targetX, ty: me.targetY, 389 - }); 169 + localWasMoving = isMoving; 390 170 } 391 171 } 392 172 ··· 394 174 sw = screen.width; 395 175 sh = screen.height; 396 176 397 - if (e.is("touch")) { 177 + if (e.is("touch") && phase === "fight") { 398 178 const ox = Math.floor(sw / 2 - ARENA_W / 2); 399 179 const oy = Math.floor(sh / 2 - ARENA_H / 2); 180 + const tx = Math.max(6, Math.min(ARENA_W - 6, e.x - ox)); 181 + const ty = Math.max(6, Math.min(ARENA_H - 6, e.y - oy)); 400 182 401 - // Tap arena to move 402 - if (phase === "fight" && (isDueling() || dummy) && me?.alive) { 403 - me.targetX = Math.max(6, Math.min(ARENA_W - 6, e.x - ox)); 404 - me.targetY = Math.max(6, Math.min(ARENA_H - 6, e.y - oy)); 405 - } 183 + inputSeq++; 184 + localTargetX = tx; 185 + localTargetY = ty; 186 + pendingInputs.push({ seq: inputSeq, targetX: tx, targetY: ty }); 187 + 188 + // Send to server via UDP 189 + udpChannel?.send("duel:input", { seq: inputSeq, targetX: tx, targetY: ty }); 406 190 } 407 191 } 408 192 ··· 418 202 ink(252, 250, 245).box(ox, oy, ARENA_W, ARENA_H); 419 203 ink(180, 175, 165).box(ox, oy, ARENA_W, ARENA_H, "outline"); 420 204 421 - // Center cross mark 205 + // Center cross 422 206 ink(230, 225, 218).box(ox + ARENA_W / 2 - 1, oy + ARENA_H / 2 - 6, 2, 12); 423 207 ink(230, 225, 218).box(ox + ARENA_W / 2 - 6, oy + ARENA_H / 2 - 1, 12, 2); 424 208 209 + const players = snap?.players || []; 210 + const bullets = snap?.bullets || []; 211 + 212 + if (phase === "waiting") { 213 + ink(110, 105, 130).write("dumduel", { x: ox + 78, y: oy + 95 }); 214 + ink(90, 85, 110).write("waiting...", { x: ox + 72, y: oy + 110 }); 215 + } 216 + 425 217 if (phase === "countdown") { 426 218 const secs = Math.ceil(countdownTimer / 60); 427 219 ink(60, 55, 45).write("" + secs, { ··· 429 221 y: oy + Math.floor(ARENA_H / 2 - 3), 430 222 }); 431 223 432 - if (me && opponent) { 433 - drawFigure(ink, circle, box, line, ox, oy, me, [50, 120, 200], frameCount); 434 - drawFigure(ink, circle, box, line, ox, oy, opponent, [200, 70, 60], frameCount); 224 + // Draw figures at spawn 225 + for (const p of players) { 226 + const col = isMe(p.handle) ? [50, 120, 200] : [200, 70, 60]; 227 + drawFigure(ink, circle, box, line, ox, oy, p, col, frameCount); 228 + } 229 + 230 + // VS text 231 + if (players.length >= 2) { 232 + const vs = players[0].handle + " vs " + players[1].handle; 233 + ink(160, 155, 145).write(vs, { 234 + x: ox + Math.floor(ARENA_W / 2 - vs.length * 3), 235 + y: oy + ARENA_H + 4, 236 + }); 435 237 } 436 238 437 - const d0 = roster[0]?.handle || "?"; 438 - const d1 = roster[1]?.handle || "?"; 439 - const vs = d0 + " vs " + d1; 440 - ink(160, 155, 145).write(vs, { 441 - x: ox + Math.floor(ARENA_W / 2 - vs.length * 3), 442 - y: oy + ARENA_H + 4, 443 - }); 239 + // Countdown tick sound 240 + if (countdownTimer > 0 && countdownTimer % 60 === 0) { 241 + synth?.({ type: "sine", tone: 330, volume: 0.5, attack: 0.005, decay: 0.1, duration: 0.12 }); 242 + } 444 243 } 445 244 446 245 if (phase === "fight" || phase === "roundover") { 447 - // Bullets (fade with age) 246 + // Bullets (extrapolated client-side for smoothness) 448 247 for (const b of bullets) { 449 248 const alpha = Math.max(40, 255 - (b.age || 0) * 1.2); 450 - if (b.owner === "me") ink(50, 120, 200, alpha); 249 + if (b.owner === myHandle) ink(50, 120, 200, alpha); 451 250 else ink(200, 70, 60, alpha); 452 251 circle(ox + Math.floor(b.x), oy + Math.floor(b.y), BULLET_R, true); 453 252 } 454 253 455 - // Target indicator (my tap destination) 456 - if (me?.alive && phase === "fight") { 457 - ink(50, 120, 200, 60).circle( 458 - ox + Math.floor(me.targetX), 459 - oy + Math.floor(me.targetY), 460 - 3, false, 461 - ); 254 + // Target indicator 255 + if (phase === "fight") { 256 + const meAlive = players.find((p) => p.handle === myHandle)?.alive; 257 + if (meAlive) { 258 + ink(50, 120, 200, 60).circle( 259 + ox + Math.floor(localTargetX), 260 + oy + Math.floor(localTargetY), 261 + 3, false, 262 + ); 263 + } 462 264 } 463 265 464 - if (me && opponent) { 465 - drawFigure(ink, circle, box, line, ox, oy, me, [50, 120, 200], frameCount); 466 - drawFigure(ink, circle, box, line, ox, oy, opponent, [200, 70, 60], frameCount); 467 - } 266 + // Draw figures — use predicted position for self 267 + for (const p of players) { 268 + const col = isMe(p.handle) ? [50, 120, 200] : [200, 70, 60]; 269 + const drawP = isMe(p.handle) 270 + ? { ...p, x: localX, y: localY, targetX: localTargetX, targetY: localTargetY } 271 + : p; 272 + drawFigure(ink, circle, box, line, ox, oy, drawP, col, frameCount); 468 273 469 - // Handle labels (MatrixChunky8, centered) 470 - if (me) { 471 - ink(50, 120, 200, 150).write(myHandle, { 472 - x: ox + Math.floor(me.x) - Math.floor(myHandle.length * 2), 473 - y: oy + Math.floor(me.y) + 9, 474 - }, undefined, undefined, false, "MatrixChunky8"); 475 - } 476 - if (opponent) { 477 - ink(200, 70, 60, 150).write(opponent.handle, { 478 - x: ox + Math.floor(opponent.x) - Math.floor(opponent.handle.length * 2), 479 - y: oy + Math.floor(opponent.y) + 9, 274 + // Handle label (MatrixChunky8, centered) + ping 275 + const label = p.handle; 276 + const pingStr = p.ping > 0 ? ` ${p.ping}` : ""; 277 + const fullLabel = label + pingStr; 278 + const lx = (isMe(p.handle) ? localX : p.x); 279 + const ly = (isMe(p.handle) ? localY : p.y); 280 + ink(...col, 150).write(fullLabel, { 281 + x: ox + Math.floor(lx) - Math.floor(fullLabel.length * 2), 282 + y: oy + Math.floor(ly) + 9, 480 283 }, undefined, undefined, false, "MatrixChunky8"); 481 284 } 482 285 286 + // Round over text 483 287 if (phase === "roundover" && roundWinner) { 484 288 const won = roundWinner === myHandle; 485 289 const msg = won ? "you got em!" : "you died!"; ··· 492 296 } 493 297 494 298 // Practice label 495 - if (dummy && (phase === "fight" || phase === "countdown")) { 299 + const isDummy = roster.some((r) => r.handle === "dummy"); 300 + if (isDummy && (phase === "fight" || phase === "countdown")) { 496 301 ink(200, 195, 185).write("practice", { 497 302 x: ox + Math.floor(ARENA_W / 2 - 24), 498 303 y: oy - 12, 499 304 }); 500 305 } 501 306 502 - // Stack 307 + // Stack (right side) 503 308 const stackX = ox + ARENA_W + 8; 504 309 const stackY = oy; 505 310 ink(160, 155, 145).write("stack", { x: stackX, y: stackY }); 506 311 let si = 0; 507 - for (let i = 0; i < roster.length; i++) { 508 - const r = roster[i]; 312 + for (const r of roster) { 509 313 if (r.handle === "dummy") continue; 510 - const isDuelist = i < 2 && roster.length >= 2; 314 + const isDuelist = si < 2 && roster.length >= 2; 511 315 if (isDuelist) ink(60, 55, 45); 512 316 else if (r.handle === myHandle) ink(120, 115, 105); 513 317 else ink(170, 165, 155); ··· 516 320 } 517 321 } 518 322 323 + function isMe(handle) { 324 + return handle === myHandle; 325 + } 326 + 519 327 function drawFigure(ink, circle, box, line, ox, oy, fig, col, fc) { 520 328 const fx = ox + Math.floor(fig.x); 521 329 const fy = oy + Math.floor(fig.y); ··· 527 335 return; 528 336 } 529 337 530 - // Leg animation based on movement 531 338 const dx = (fig.targetX || fig.x) - fig.x; 532 339 const dy = (fig.targetY || fig.y) - fig.y; 533 340 const moving = dx * dx + dy * dy > 4; 534 341 const legSwing = moving ? Math.sin(fc * 0.3) * 3 : 0; 535 342 536 343 ink(...col); 537 - // Legs 538 344 line(fx, fy + 1, fx - 4 + legSwing, fy + 6); 539 345 line(fx, fy + 1, fx + 4 - legSwing, fy + 6); 540 - // Arms 541 346 line(fx - 1, fy - 1, fx - 5 - legSwing * 0.5, fy + 2); 542 347 line(fx + 1, fy - 1, fx + 5 + legSwing * 0.5, fy + 2); 543 - // Head 544 348 circle(fx, fy - 2, 3, true); 545 - // Eye dot 546 349 ink(255, 255, 255).box(fx, fy - 3, 1, 1); 547 350 } 548 351 549 352 function meta() { 550 353 return { 551 354 title: "Dumduel", 552 - desc: "Top-down stick figure shootout. Tap to dodge. Instant death.", 355 + desc: "Top-down stick figure shootout. Server-authoritative netcode.", 553 356 }; 554 357 } 555 358