Push balls to opponent's side online. That's it. That's the game. google-balls-the-thrusting-production.up.railway.app
googleballs
at main 318 lines 7.7 kB view raw
1import homepage from "./public/index.html"; 2import type { Server, ServerWebSocket } from "bun"; 3import { puckColors } from "./theme"; 4 5type WsData = { id: string; ip: string }; 6 7type Puck = { 8 id: number; 9 x: number; 10 y: number; 11 vx: number; 12 vy: number; 13 color: string; 14}; 15type Player = { 16 id: string; 17 team: "left" | "right"; 18 activeThisRound: boolean; 19 ws: ServerWebSocket<WsData>; 20}; 21 22const R = 25; 23const FRICTION = 0.98; 24const FORCE = 15; 25const MIN_PUCKS = 4; 26const MAX_PLAYERS = 50; 27const MAX_PER_IP = 3; 28const MIN_W = 1000, 29 MIN_H = 600; 30const MAX_W = 1600, 31 MAX_H = 900; 32const ROUND_TIME = 60; 33const FPS = 60; 34 35let W = MIN_W, 36 H = MIN_H; 37let pucks: Puck[] = []; 38let timeLeft = ROUND_TIME; 39let winner: "left" | "right" | "tie" | null = null; 40 41const players = new Map<string, Player>(); 42const playersAtRoundStart = new Set<string>(); 43const queue: string[] = []; 44const ipConnections = new Map<string, number>(); 45 46function resize() { 47 const oldW = W, 48 oldH = H; 49 W = Math.min(MAX_W, MIN_W + players.size * 50); 50 H = Math.min(MAX_H, MIN_H + players.size * 30); 51 if (oldW !== W || oldH !== H) { 52 const scaleX = W / oldW, 53 scaleY = H / oldH; 54 for (const p of pucks) { 55 p.x *= scaleX; 56 p.y *= scaleY; 57 } 58 } 59} 60 61function targetPucks() { 62 return MIN_PUCKS + Math.floor(players.size / 2); 63} 64 65function resetPucks() { 66 const count = Math.max(MIN_PUCKS, targetPucks()); 67 const radius = 40 + count * 8; 68 pucks = Array.from({ length: count }, (_, i) => { 69 const angle = (i / count) * Math.PI * 2; 70 const r = radius * (0.4 + Math.random() * 0.6); 71 return { 72 id: i, 73 x: W / 2 + Math.cos(angle) * r + (Math.random() - 0.5) * 30, 74 y: H / 2 + Math.sin(angle) * r + (Math.random() - 0.5) * 30, 75 vx: 0, 76 vy: 0, 77 color: puckColors[i % puckColors.length]!, 78 }; 79 }); 80} 81 82function adjustPucks() { 83 while (pucks.length < targetPucks()) { 84 const i = pucks.length; 85 pucks.push({ 86 id: i, 87 x: W / 2 + (Math.random() - 0.5) * 100, 88 y: H / 2 + (Math.random() - 0.5) * 100, 89 vx: 0, 90 vy: 0, 91 color: puckColors[i % puckColors.length]!, 92 }); 93 } 94} 95 96function physics() { 97 if (winner) return; 98 for (const p of pucks) { 99 p.vx *= FRICTION; 100 p.vy *= FRICTION; 101 p.x += p.vx; 102 p.y += p.vy; 103 if (p.x < R) { 104 p.x = R; 105 p.vx *= -0.8; 106 } 107 if (p.x > W - R) { 108 p.x = W - R; 109 p.vx *= -0.8; 110 } 111 if (p.y < R) { 112 p.y = R; 113 p.vy *= -0.8; 114 } 115 if (p.y > H - R) { 116 p.y = H - R; 117 p.vy *= -0.8; 118 } 119 if (Math.abs(p.vx) < 0.1) p.vx = 0; 120 if (Math.abs(p.vy) < 0.1) p.vy = 0; 121 } 122 for (let i = 0; i < pucks.length; i++) { 123 for (let j = i + 1; j < pucks.length; j++) { 124 const a = pucks[i]!, 125 b = pucks[j]!; 126 const dx = b.x - a.x, 127 dy = b.y - a.y; 128 const dist = Math.hypot(dx, dy); 129 if (dist < R * 2 && dist > 0) { 130 const nx = dx / dist, 131 ny = dy / dist; 132 const overlap = (R * 2 - dist) / 2; 133 a.x -= nx * overlap; 134 a.y -= ny * overlap; 135 b.x += nx * overlap; 136 b.y += ny * overlap; 137 const dot = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny; 138 a.vx -= dot * nx; 139 a.vy -= dot * ny; 140 b.vx += dot * nx; 141 b.vy += dot * ny; 142 } 143 } 144 } 145} 146 147function getScore() { 148 let left = 0, 149 right = 0; 150 for (const p of pucks) p.x < W / 2 ? left++ : right++; 151 return { left, right }; 152} 153 154function countTeams() { 155 let left = 0, 156 right = 0; 157 for (const p of players.values()) p.team === "left" ? left++ : right++; 158 return { left, right }; 159} 160 161function state() { 162 const score = getScore(); 163 const teams = countTeams(); 164 return { 165 pucks, 166 leftScore: score.left, 167 rightScore: score.right, 168 leftPlayers: teams.left, 169 rightPlayers: teams.right, 170 canvasWidth: W, 171 canvasHeight: H, 172 timeLeft, 173 winner, 174 }; 175} 176 177function assignTeam(): "left" | "right" { 178 const teams = countTeams(); 179 return teams.left <= teams.right ? "left" : "right"; 180} 181 182function kickInactivePlayers() { 183 for (const [id, player] of players) { 184 if (playersAtRoundStart.has(id) && !player.activeThisRound) { 185 player.ws.close(1000, "Inactive"); 186 } 187 } 188} 189 190function startRound() { 191 playersAtRoundStart.clear(); 192 for (const [id, player] of players) { 193 player.activeThisRound = false; 194 playersAtRoundStart.add(id); 195 } 196 resize(); 197 resetPucks(); 198 timeLeft = ROUND_TIME; 199 winner = null; 200} 201 202function endRound() { 203 const score = getScore(); 204 winner = 205 score.left < score.right 206 ? "left" 207 : score.right < score.left 208 ? "right" 209 : "tie"; 210 kickInactivePlayers(); 211 setTimeout(startRound, 3000); 212} 213 214resetPucks(); 215 216const server = Bun.serve<WsData>({ 217 port: process.env.PORT || 3000, 218 development: process.env.NODE_ENV !== "production", 219 routes: { 220 "/": homepage, 221 "/ws": { 222 GET(req: Request, srv: Server<WsData>) { 223 const ip = 224 req.headers.get("x-forwarded-for")?.split(",")[0] || "unknown"; 225 const count = ipConnections.get(ip) || 0; 226 if (count >= MAX_PER_IP) { 227 return new Response("Too many connections", { status: 429 }); 228 } 229 ipConnections.set(ip, count + 1); 230 return srv.upgrade(req, { data: { id: crypto.randomUUID(), ip } }) 231 ? undefined 232 : new Response("Upgrade failed", { status: 400 }); 233 }, 234 }, 235 }, 236 fetch() { 237 return new Response("Not found", { status: 404 }); 238 }, 239 websocket: { 240 open(ws) { 241 const { id } = ws.data; 242 if (players.size >= MAX_PLAYERS) { 243 queue.push(id); 244 ws.subscribe("game"); 245 ws.send( 246 JSON.stringify({ 247 type: "queued", 248 queuePosition: queue.length, 249 ...state(), 250 }), 251 ); 252 return; 253 } 254 players.set(id, { id, team: assignTeam(), activeThisRound: true, ws }); 255 ws.subscribe("game"); 256 resize(); 257 adjustPucks(); 258 ws.send( 259 JSON.stringify({ 260 type: "init", 261 team: players.get(id)!.team, 262 ...state(), 263 }), 264 ); 265 }, 266 message(ws, msg) { 267 if (winner) return; 268 const data = JSON.parse(msg.toString()); 269 const player = players.get(ws.data.id); 270 if (data.type !== "push" || !player) return; 271 player.activeThisRound = true; 272 const { x, y, dx, dy } = data; 273 const mid = W / 2; 274 if (player.team === "left" ? x >= mid : x < mid) return; 275 let nearest: Puck | null = null, 276 minDist = Infinity; 277 for (const p of pucks) { 278 if (player.team === "left" ? p.x >= mid : p.x < mid) continue; 279 const d = Math.hypot(p.x - x, p.y - y); 280 if (d < minDist && d < R * 2) { 281 minDist = d; 282 nearest = p; 283 } 284 } 285 if (nearest) { 286 const mag = Math.hypot(dx, dy); 287 if (mag > 0) { 288 nearest.vx += (dx / mag) * FORCE; 289 nearest.vy += (dy / mag) * FORCE; 290 } 291 } 292 }, 293 close(ws) { 294 const { id, ip } = ws.data; 295 const count = ipConnections.get(ip) || 1; 296 if (count <= 1) ipConnections.delete(ip); 297 else ipConnections.set(ip, count - 1); 298 const qi = queue.indexOf(id); 299 if (qi !== -1) { 300 queue.splice(qi, 1); 301 return; 302 } 303 players.delete(id); 304 players.size === 0 ? startRound() : resize(); 305 }, 306 }, 307}); 308 309setInterval(() => { 310 physics(); 311 server.publish("game", JSON.stringify({ type: "state", ...state() })); 312}, 1000 / FPS); 313 314setInterval(() => { 315 if (!winner && players.size > 0 && --timeLeft <= 0) endRound(); 316}, 1000); 317 318console.log(`http://localhost:${server.port}`);