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}`);