import homepage from "./public/index.html"; import type { Server, ServerWebSocket } from "bun"; import { puckColors } from "./theme"; type WsData = { id: string; ip: string }; type Puck = { id: number; x: number; y: number; vx: number; vy: number; color: string; }; type Player = { id: string; team: "left" | "right"; activeThisRound: boolean; ws: ServerWebSocket; }; const R = 25; const FRICTION = 0.98; const FORCE = 15; const MIN_PUCKS = 4; const MAX_PLAYERS = 50; const MAX_PER_IP = 3; const MIN_W = 1000, MIN_H = 600; const MAX_W = 1600, MAX_H = 900; const ROUND_TIME = 60; const FPS = 60; let W = MIN_W, H = MIN_H; let pucks: Puck[] = []; let timeLeft = ROUND_TIME; let winner: "left" | "right" | "tie" | null = null; const players = new Map(); const playersAtRoundStart = new Set(); const queue: string[] = []; const ipConnections = new Map(); function resize() { const oldW = W, oldH = H; W = Math.min(MAX_W, MIN_W + players.size * 50); H = Math.min(MAX_H, MIN_H + players.size * 30); if (oldW !== W || oldH !== H) { const scaleX = W / oldW, scaleY = H / oldH; for (const p of pucks) { p.x *= scaleX; p.y *= scaleY; } } } function targetPucks() { return MIN_PUCKS + Math.floor(players.size / 2); } function resetPucks() { const count = Math.max(MIN_PUCKS, targetPucks()); const radius = 40 + count * 8; pucks = Array.from({ length: count }, (_, i) => { const angle = (i / count) * Math.PI * 2; const r = radius * (0.4 + Math.random() * 0.6); return { id: i, x: W / 2 + Math.cos(angle) * r + (Math.random() - 0.5) * 30, y: H / 2 + Math.sin(angle) * r + (Math.random() - 0.5) * 30, vx: 0, vy: 0, color: puckColors[i % puckColors.length]!, }; }); } function adjustPucks() { while (pucks.length < targetPucks()) { const i = pucks.length; pucks.push({ id: i, x: W / 2 + (Math.random() - 0.5) * 100, y: H / 2 + (Math.random() - 0.5) * 100, vx: 0, vy: 0, color: puckColors[i % puckColors.length]!, }); } } function physics() { if (winner) return; for (const p of pucks) { p.vx *= FRICTION; p.vy *= FRICTION; p.x += p.vx; p.y += p.vy; if (p.x < R) { p.x = R; p.vx *= -0.8; } if (p.x > W - R) { p.x = W - R; p.vx *= -0.8; } if (p.y < R) { p.y = R; p.vy *= -0.8; } if (p.y > H - R) { p.y = H - R; p.vy *= -0.8; } if (Math.abs(p.vx) < 0.1) p.vx = 0; if (Math.abs(p.vy) < 0.1) p.vy = 0; } for (let i = 0; i < pucks.length; i++) { for (let j = i + 1; j < pucks.length; j++) { const a = pucks[i]!, b = pucks[j]!; const dx = b.x - a.x, dy = b.y - a.y; const dist = Math.hypot(dx, dy); if (dist < R * 2 && dist > 0) { const nx = dx / dist, ny = dy / dist; const overlap = (R * 2 - dist) / 2; a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap; const dot = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny; a.vx -= dot * nx; a.vy -= dot * ny; b.vx += dot * nx; b.vy += dot * ny; } } } } function getScore() { let left = 0, right = 0; for (const p of pucks) p.x < W / 2 ? left++ : right++; return { left, right }; } function countTeams() { let left = 0, right = 0; for (const p of players.values()) p.team === "left" ? left++ : right++; return { left, right }; } function state() { const score = getScore(); const teams = countTeams(); return { pucks, leftScore: score.left, rightScore: score.right, leftPlayers: teams.left, rightPlayers: teams.right, canvasWidth: W, canvasHeight: H, timeLeft, winner, }; } function assignTeam(): "left" | "right" { const teams = countTeams(); return teams.left <= teams.right ? "left" : "right"; } function kickInactivePlayers() { for (const [id, player] of players) { if (playersAtRoundStart.has(id) && !player.activeThisRound) { player.ws.close(1000, "Inactive"); } } } function startRound() { playersAtRoundStart.clear(); for (const [id, player] of players) { player.activeThisRound = false; playersAtRoundStart.add(id); } resize(); resetPucks(); timeLeft = ROUND_TIME; winner = null; } function endRound() { const score = getScore(); winner = score.left < score.right ? "left" : score.right < score.left ? "right" : "tie"; kickInactivePlayers(); setTimeout(startRound, 3000); } resetPucks(); const server = Bun.serve({ port: process.env.PORT || 3000, development: process.env.NODE_ENV !== "production", routes: { "/": homepage, "/ws": { GET(req: Request, srv: Server) { const ip = req.headers.get("x-forwarded-for")?.split(",")[0] || "unknown"; const count = ipConnections.get(ip) || 0; if (count >= MAX_PER_IP) { return new Response("Too many connections", { status: 429 }); } ipConnections.set(ip, count + 1); return srv.upgrade(req, { data: { id: crypto.randomUUID(), ip } }) ? undefined : new Response("Upgrade failed", { status: 400 }); }, }, }, fetch() { return new Response("Not found", { status: 404 }); }, websocket: { open(ws) { const { id } = ws.data; if (players.size >= MAX_PLAYERS) { queue.push(id); ws.subscribe("game"); ws.send( JSON.stringify({ type: "queued", queuePosition: queue.length, ...state(), }), ); return; } players.set(id, { id, team: assignTeam(), activeThisRound: true, ws }); ws.subscribe("game"); resize(); adjustPucks(); ws.send( JSON.stringify({ type: "init", team: players.get(id)!.team, ...state(), }), ); }, message(ws, msg) { if (winner) return; const data = JSON.parse(msg.toString()); const player = players.get(ws.data.id); if (data.type !== "push" || !player) return; player.activeThisRound = true; const { x, y, dx, dy } = data; const mid = W / 2; if (player.team === "left" ? x >= mid : x < mid) return; let nearest: Puck | null = null, minDist = Infinity; for (const p of pucks) { if (player.team === "left" ? p.x >= mid : p.x < mid) continue; const d = Math.hypot(p.x - x, p.y - y); if (d < minDist && d < R * 2) { minDist = d; nearest = p; } } if (nearest) { const mag = Math.hypot(dx, dy); if (mag > 0) { nearest.vx += (dx / mag) * FORCE; nearest.vy += (dy / mag) * FORCE; } } }, close(ws) { const { id, ip } = ws.data; const count = ipConnections.get(ip) || 1; if (count <= 1) ipConnections.delete(ip); else ipConnections.set(ip, count - 1); const qi = queue.indexOf(id); if (qi !== -1) { queue.splice(qi, 1); return; } players.delete(id); players.size === 0 ? startRound() : resize(); }, }, }); setInterval(() => { physics(); server.publish("game", JSON.stringify({ type: "state", ...state() })); }, 1000 / FPS); setInterval(() => { if (!winner && players.size > 0 && --timeLeft <= 0) endRound(); }, 1000); console.log(`http://localhost:${server.port}`);