Push balls to opponent's side online. That's it. That's the game.
google-balls-the-thrusting-production.up.railway.app
googleballs
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}`);