Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

Add dumduel: stick figure shootout with queue system

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

+469
+469
system/public/aesthetic.computer/disks/dumduel.mjs
··· 1 + // Dumduel, 2026.03.30 2 + // Stick figure shootout. Tap to run, dodge bullets, instant death. 3 + // Two duelists at a time — everyone else waits in the stack. 4 + 5 + const ARENA_W = 200; 6 + const ARENA_H = 140; 7 + const GROUND_Y = ARENA_H - 16; 8 + const FIGURE_H = 16; 9 + const FIGURE_W = 6; 10 + const BULLET_SPEED = 2.5; 11 + const BULLET_R = 2; 12 + const MOVE_SPEED = 1.2; 13 + const FIRE_INTERVAL = 90; // frames between auto-shots (~1.5s) 14 + const COUNTDOWN_FRAMES = 180; // 3 seconds 15 + const ROUND_OVER_FRAMES = 120; // 2 seconds pause after kill 16 + const HIT_R = 5; // hitbox radius 17 + 18 + // -- State -- 19 + let server, udpChannel; 20 + let myHandle = "guest"; 21 + let myId = null; 22 + let sw = 0, sh = 0; 23 + let frameCount = 0; 24 + 25 + // All connected players in order: [{ id, handle }] 26 + // First two are the duelists, rest are the stack 27 + let roster = []; 28 + 29 + // Game phase: "waiting" | "countdown" | "fight" | "roundover" 30 + let phase = "waiting"; 31 + let countdownTimer = 0; 32 + let roundOverTimer = 0; 33 + let roundWinner = null; // handle of winner 34 + 35 + // Local duelist state (only active when I'm dueling) 36 + let me = null; // { x, y, targetX, alive, fireTimer } 37 + let opponent = null; // { x, y, targetX, alive, fireTimer, handle } 38 + let bullets = []; // { x, y, vx, owner: "me"|"them" } 39 + let mySlot = -1; // 0 = left, 1 = right 40 + 41 + function isDueling() { 42 + return roster.length >= 2 && 43 + (roster[0].handle === myHandle || roster[1].handle === myHandle); 44 + } 45 + 46 + function myRosterIdx() { 47 + return roster.findIndex((r) => r.handle === myHandle); 48 + } 49 + 50 + function spawnDuelists() { 51 + bullets = []; 52 + me = { 53 + x: mySlot === 0 ? 30 : ARENA_W - 30, 54 + y: GROUND_Y, 55 + targetX: mySlot === 0 ? 30 : ARENA_W - 30, 56 + alive: true, 57 + fireTimer: FIRE_INTERVAL, 58 + }; 59 + opponent = { 60 + x: mySlot === 0 ? ARENA_W - 30 : 30, 61 + y: GROUND_Y, 62 + targetX: mySlot === 0 ? ARENA_W - 30 : 30, 63 + alive: true, 64 + fireTimer: FIRE_INTERVAL, 65 + handle: roster[mySlot === 0 ? 1 : 0]?.handle || "???", 66 + }; 67 + } 68 + 69 + function startCountdown() { 70 + phase = "countdown"; 71 + countdownTimer = COUNTDOWN_FRAMES; 72 + 73 + // Determine slots 74 + const idx = myRosterIdx(); 75 + if (idx === 0) mySlot = 0; 76 + else if (idx === 1) mySlot = 1; 77 + else mySlot = -1; // spectating 78 + 79 + if (isDueling()) spawnDuelists(); 80 + } 81 + 82 + function startFight() { 83 + phase = "fight"; 84 + if (isDueling() && !me) spawnDuelists(); 85 + } 86 + 87 + function endRound(winnerHandle) { 88 + roundWinner = winnerHandle; 89 + phase = "roundover"; 90 + roundOverTimer = ROUND_OVER_FRAMES; 91 + } 92 + 93 + function advanceStack() { 94 + // Loser goes to bottom of stack 95 + if (roster.length >= 2) { 96 + const loserIdx = roster[0].handle === roundWinner ? 1 : 0; 97 + const loser = roster.splice(loserIdx, 1)[0]; 98 + roster.push(loser); 99 + } 100 + roundWinner = null; 101 + me = null; 102 + opponent = null; 103 + bullets = []; 104 + 105 + if (roster.length >= 2) { 106 + startCountdown(); 107 + } else { 108 + phase = "waiting"; 109 + } 110 + } 111 + 112 + function boot({ wipe, screen, net: { socket, udp }, handle }) { 113 + sw = screen.width; 114 + sh = screen.height; 115 + myHandle = handle?.() || "guest_" + Math.floor(Math.random() * 9999); 116 + 117 + udpChannel = udp((type, content) => { 118 + const d = typeof content === "string" ? JSON.parse(content) : content; 119 + if (d.handle === myHandle) return; 120 + 121 + if (type === "duel:pos" && opponent) { 122 + opponent.x = d.x; 123 + opponent.y = d.y; 124 + opponent.targetX = d.targetX; 125 + } 126 + }); 127 + 128 + server = socket((id, type, content) => { 129 + if (type.startsWith("connected")) { 130 + myId = id; 131 + server.send("duel:join", { handle: myHandle }); 132 + return; 133 + } 134 + 135 + if (type === "left") { 136 + const idx = roster.findIndex((r) => r.id === id); 137 + if (idx >= 0) { 138 + const wasDueling = idx < 2 && roster.length >= 2; 139 + roster.splice(idx, 1); 140 + if (wasDueling && (phase === "fight" || phase === "countdown")) { 141 + // Opponent left mid-duel — current player wins 142 + if (isDueling()) { 143 + endRound(myHandle); 144 + } else { 145 + phase = "waiting"; 146 + me = null; opponent = null; bullets = []; 147 + } 148 + } 149 + } 150 + return; 151 + } 152 + 153 + const msg = typeof content === "string" ? JSON.parse(content) : content; 154 + 155 + if (type === "duel:join" && msg.handle !== myHandle) { 156 + if (!roster.find((r) => r.handle === msg.handle)) { 157 + roster.push({ id, handle: msg.handle }); 158 + } 159 + // Send full roster to newcomer 160 + server.send("duel:roster", { 161 + handle: myHandle, 162 + roster: roster.map((r) => ({ handle: r.handle })), 163 + phase, 164 + }); 165 + // If we now have 2+ and were waiting, start 166 + if (roster.length >= 2 && phase === "waiting") { 167 + startCountdown(); 168 + server.send("duel:countdown", { handle: myHandle }); 169 + } 170 + } 171 + 172 + if (type === "duel:roster") { 173 + // Sync roster from existing player 174 + if (roster.length <= 1) { 175 + for (const r of msg.roster) { 176 + if (!roster.find((x) => x.handle === r.handle)) { 177 + roster.push({ id: r.handle === msg.handle ? id : "?", handle: r.handle }); 178 + } 179 + } 180 + if (msg.phase === "countdown" || msg.phase === "fight") { 181 + startCountdown(); 182 + } 183 + } 184 + } 185 + 186 + if (type === "duel:countdown") { 187 + if (roster.length >= 2 && phase === "waiting") { 188 + startCountdown(); 189 + } 190 + } 191 + 192 + if (type === "duel:fire") { 193 + // Opponent fired a bullet 194 + if (phase === "fight") { 195 + bullets.push({ 196 + x: msg.x, y: msg.y, vx: msg.vx, owner: "them", 197 + }); 198 + } 199 + } 200 + 201 + if (type === "duel:hit") { 202 + // Someone got hit — trust the shooter's claim 203 + if (phase === "fight") { 204 + if (msg.victim === myHandle) { 205 + if (me) me.alive = false; 206 + endRound(msg.handle); 207 + server.send("duel:roundover", { winner: msg.handle }); 208 + } 209 + } 210 + } 211 + 212 + if (type === "duel:roundover") { 213 + if (phase === "fight") { 214 + endRound(msg.winner); 215 + if (opponent && msg.winner === myHandle) { 216 + opponent.alive = false; 217 + } else if (me && msg.winner !== myHandle) { 218 + me.alive = false; 219 + } 220 + } 221 + } 222 + 223 + if (type === "duel:advance") { 224 + advanceStack(); 225 + } 226 + }); 227 + 228 + // Add self to roster 229 + roster.push({ id: myId, handle: myHandle }); 230 + 231 + wipe(30, 25, 40); 232 + } 233 + 234 + function sim() { 235 + frameCount++; 236 + 237 + if (phase === "countdown") { 238 + countdownTimer--; 239 + if (countdownTimer <= 0) startFight(); 240 + } 241 + 242 + if (phase === "roundover") { 243 + roundOverTimer--; 244 + if (roundOverTimer <= 0) { 245 + advanceStack(); 246 + server?.send("duel:advance", { handle: myHandle }); 247 + } 248 + } 249 + 250 + if (phase !== "fight" || !isDueling() || !me) return; 251 + 252 + // Move toward target 253 + if (me.alive) { 254 + const dx = me.targetX - me.x; 255 + if (Math.abs(dx) > 1) me.x += Math.sign(dx) * MOVE_SPEED; 256 + else me.x = me.targetX; 257 + } 258 + 259 + // Opponent movement (interpolated from UDP) 260 + if (opponent) { 261 + const dx = opponent.targetX - opponent.x; 262 + if (Math.abs(dx) > 1) opponent.x += Math.sign(dx) * MOVE_SPEED; 263 + } 264 + 265 + // Auto-fire 266 + if (me.alive) { 267 + me.fireTimer--; 268 + if (me.fireTimer <= 0) { 269 + me.fireTimer = FIRE_INTERVAL; 270 + const dir = opponent ? Math.sign(opponent.x - me.x) : (mySlot === 0 ? 1 : -1); 271 + const bx = me.x + dir * 4; 272 + const by = me.y - FIGURE_H / 2; 273 + bullets.push({ x: bx, y: by, vx: dir * BULLET_SPEED, owner: "me" }); 274 + server?.send("duel:fire", { 275 + handle: myHandle, x: bx, y: by, vx: dir * BULLET_SPEED, 276 + }); 277 + } 278 + } 279 + 280 + // Update bullets 281 + for (let i = bullets.length - 1; i >= 0; i--) { 282 + const b = bullets[i]; 283 + b.x += b.vx; 284 + 285 + // Off screen 286 + if (b.x < -10 || b.x > ARENA_W + 10) { 287 + bullets.splice(i, 1); 288 + continue; 289 + } 290 + 291 + // Hit detection 292 + if (b.owner === "them" && me.alive) { 293 + const dx = b.x - me.x; 294 + const dy = b.y - (me.y - FIGURE_H / 2); 295 + if (dx * dx + dy * dy < HIT_R * HIT_R) { 296 + me.alive = false; 297 + bullets.splice(i, 1); 298 + endRound(opponent?.handle || "???"); 299 + server?.send("duel:roundover", { winner: opponent?.handle || "???" }); 300 + break; 301 + } 302 + } 303 + if (b.owner === "me" && opponent?.alive) { 304 + const dx = b.x - opponent.x; 305 + const dy = b.y - (opponent.y - FIGURE_H / 2); 306 + if (dx * dx + dy * dy < HIT_R * HIT_R) { 307 + opponent.alive = false; 308 + bullets.splice(i, 1); 309 + endRound(myHandle); 310 + server?.send("duel:hit", { 311 + handle: myHandle, victim: opponent.handle, 312 + }); 313 + server?.send("duel:roundover", { winner: myHandle }); 314 + break; 315 + } 316 + } 317 + } 318 + 319 + // Send position via UDP every 3 frames 320 + if (frameCount % 3 === 0 && udpChannel?.connected && me.alive) { 321 + udpChannel.send("duel:pos", { 322 + handle: myHandle, 323 + x: me.x, 324 + y: me.y, 325 + targetX: me.targetX, 326 + }); 327 + } 328 + } 329 + 330 + function act({ event: e, screen }) { 331 + sw = screen.width; 332 + sh = screen.height; 333 + 334 + if (e.is("touch") && phase === "fight" && isDueling() && me?.alive) { 335 + // Tap to set run target (screen -> arena coords) 336 + const ox = Math.floor(sw / 2 - ARENA_W / 2); 337 + const tx = e.x - ox; 338 + me.targetX = Math.max(8, Math.min(ARENA_W - 8, tx)); 339 + } 340 + } 341 + 342 + function paint({ wipe, ink, box, write, line, circle, screen }) { 343 + sw = screen.width; 344 + sh = screen.height; 345 + wipe(30, 25, 40); 346 + 347 + const ox = Math.floor(sw / 2 - ARENA_W / 2); 348 + const oy = Math.floor(sh / 2 - ARENA_H / 2); 349 + 350 + // Arena bg 351 + ink(45, 38, 55).box(ox, oy, ARENA_W, ARENA_H); 352 + // Ground 353 + ink(60, 52, 70).box(ox, oy + GROUND_Y, ARENA_W, ARENA_H - GROUND_Y); 354 + // Arena border 355 + ink(70, 62, 85).box(ox, oy, ARENA_W, ARENA_H, "outline"); 356 + 357 + if (phase === "waiting") { 358 + ink(110, 105, 130).write("dumduel", { x: ox + 72, y: oy + 50 }); 359 + ink(80, 75, 100).write("waiting...", { x: ox + 66, y: oy + 65 }); 360 + } 361 + 362 + if (phase === "countdown") { 363 + const secs = Math.ceil(countdownTimer / 60); 364 + const numStr = "" + secs; 365 + ink(255, 220, 100).write(numStr, { 366 + x: ox + Math.floor(ARENA_W / 2 - numStr.length * 3), 367 + y: oy + 30, 368 + }); 369 + 370 + // Draw duelists standing still 371 + if (isDueling() && me && opponent) { 372 + drawStickFigure(ink, line, circle, box, ox, oy, me, [120, 180, 255]); 373 + drawStickFigure(ink, line, circle, box, ox, oy, opponent, [255, 130, 100]); 374 + } 375 + 376 + // Show who vs who 377 + const d0 = roster[0]?.handle || "?"; 378 + const d1 = roster[1]?.handle || "?"; 379 + const vsStr = d0 + " vs " + d1; 380 + ink(150, 145, 170).write(vsStr, { 381 + x: ox + Math.floor(ARENA_W / 2 - vsStr.length * 3), 382 + y: oy + 50, 383 + }); 384 + } 385 + 386 + if (phase === "fight" || phase === "roundover") { 387 + // Bullets 388 + for (const b of bullets) { 389 + if (b.owner === "me") ink(120, 180, 255); 390 + else ink(255, 130, 100); 391 + circle(ox + Math.floor(b.x), oy + Math.floor(b.y), BULLET_R, true); 392 + } 393 + 394 + // Draw figures 395 + if (isDueling() && me && opponent) { 396 + drawStickFigure(ink, line, circle, box, ox, oy, me, [120, 180, 255]); 397 + drawStickFigure(ink, line, circle, box, ox, oy, opponent, [255, 130, 100]); 398 + } 399 + 400 + // Round over text 401 + if (phase === "roundover" && roundWinner) { 402 + const won = roundWinner === myHandle; 403 + const msg = won ? "you got em!" : "you died!"; 404 + if (won) ink(100, 220, 130); else ink(220, 100, 100); 405 + write(msg, { 406 + x: ox + Math.floor(ARENA_W / 2 - msg.length * 3), 407 + y: oy + 20, 408 + }); 409 + } 410 + 411 + // Show who vs who 412 + if (roster.length >= 2) { 413 + const d0 = roster[0]?.handle || "?"; 414 + const d1 = roster[1]?.handle || "?"; 415 + ink(80, 75, 100).write(d0, { x: ox + 2, y: oy + ARENA_H + 4 }); 416 + ink(80, 75, 100).write(d1, { 417 + x: ox + ARENA_W - d1.length * 6 - 2, y: oy + ARENA_H + 4, 418 + }); 419 + } 420 + } 421 + 422 + // -- Stack (queue) display -- 423 + const stackX = ox + ARENA_W + 8; 424 + const stackY = oy; 425 + ink(80, 75, 100).write("stack", { x: stackX, y: stackY }); 426 + for (let i = 0; i < roster.length; i++) { 427 + const r = roster[i]; 428 + const isMe = r.handle === myHandle; 429 + const isDuelist = i < 2 && roster.length >= 2; 430 + if (isDuelist) ink(255, 220, 100); 431 + else if (isMe) ink(150, 145, 170); 432 + else ink(100, 95, 120); 433 + write(r.handle, { x: stackX, y: stackY + 12 + i * 10 }); 434 + } 435 + } 436 + 437 + function drawStickFigure(ink, line, circle, box, ox, oy, fig, col) { 438 + const fx = ox + Math.floor(fig.x); 439 + const fy = oy + Math.floor(fig.y); 440 + 441 + if (!fig.alive) { 442 + // Dead — fallen over 443 + ink(col[0], col[1], col[2], 120); 444 + line(fx - 6, fy - 1, fx + 6, fy - 1); 445 + circle(fx + 7, fy - 2, 2, true); 446 + return; 447 + } 448 + 449 + ink(...col); 450 + // Head 451 + circle(fx, fy - FIGURE_H, 3, true); 452 + // Body 453 + line(fx, fy - FIGURE_H + 3, fx, fy - 4); 454 + // Arms 455 + line(fx - 4, fy - FIGURE_H + 7, fx + 4, fy - FIGURE_H + 7); 456 + // Legs 457 + line(fx, fy - 4, fx - 3, fy); 458 + line(fx, fy - 4, fx + 3, fy); 459 + } 460 + 461 + function meta() { 462 + return { 463 + title: "Dumduel", 464 + desc: "Stick figure shootout. Tap to dodge. Instant death. Winner stays.", 465 + }; 466 + } 467 + 468 + export { boot, sim, act, paint, meta }; 469 + export const desc = "Stick figure shootout.";