Initial commit

This commit is contained in:
Mike Müller 2026-03-08 11:38:28 +01:00
parent 1cd6937f90
commit 82a124f900
22 changed files with 3125 additions and 2 deletions

View File

@ -1,3 +1,41 @@
# gpt-bomber
# GPT Bomber
GPT codex written bomberman clone
A fully original Bomberman-style browser game built from scratch.
## Features
- Grid-based bomb combat with chain reactions
- Destructible crates and hidden powerups (bomb, flame, speed)
- 3 CPU opponents with survival + attack behavior
- Original visual assets (logo, characters, tiles, icons) in local SVG files
- Original procedural audio (looping music + SFX) synthesized with Web Audio
- Keyboard and controller support
- Startup menu with mode selection (`Single Player` / `Multiplayer`) and music toggle
- Multiplayer name setting from menu (`Name: ...`) with local persistence
- Multiplayer lobby with host-controlled start (can start with 1-4 players)
- After each multiplayer round, host can start the next round directly (no lobby required)
- Host-authoritative multiplayer sync via WebSocket backend
## Run
Install dependencies and run the backend server:
```bash
npm install
npm start
```
Then open:
```text
http://localhost:8080
```
## Controls
- Move: `WASD` or `Arrow Keys`
- Bomb: `Space` or `Enter`
- Menu: `Esc` / `P`
- Menu Select: `Enter` / `Space`
- Controller: `D-pad` move, `A` bomb/select, `Start` open menu
## Multiplayer Notes
- For LAN/internet play, run the server on a reachable machine and open `http://<server-ip>:8080` from each client.
- The first active lobby player becomes host and can start with fewer than 4 players.

View File

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 190">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#041b2d"/>
<stop offset="1" stop-color="#102c45"/>
</linearGradient>
<linearGradient id="title" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffed66"/>
<stop offset="1" stop-color="#ff7a59"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="760" height="190" rx="22" fill="url(#bg)"/>
<g opacity="0.22" fill="#94d2ff">
<rect x="22" y="18" width="16" height="16"/>
<rect x="52" y="18" width="16" height="16"/>
<rect x="82" y="18" width="16" height="16"/>
<rect x="112" y="18" width="16" height="16"/>
<rect x="642" y="156" width="16" height="16"/>
<rect x="672" y="156" width="16" height="16"/>
<rect x="702" y="156" width="16" height="16"/>
</g>
<g transform="translate(34,35)">
<rect x="0" y="0" width="112" height="112" rx="18" fill="#142f4a" stroke="#4ac0ff" stroke-width="6"/>
<circle cx="56" cy="56" r="27" fill="#111" stroke="#f5f7ff" stroke-width="6"/>
<rect x="52" y="12" width="8" height="22" rx="4" fill="#f8f8f8"/>
<circle cx="56" cy="10" r="8" fill="#ff8659"/>
<path d="M56 2 C66 4 68 12 60 16 C63 10 59 8 56 2Z" fill="#ffe37a"/>
</g>
<g transform="translate(170,48)" fill="url(#title)" filter="url(#glow)">
<text x="0" y="56" font-size="68" font-family="Trebuchet MS, Verdana, sans-serif" font-weight="900" letter-spacing="2">NEON</text>
<text x="0" y="120" font-size="68" font-family="Trebuchet MS, Verdana, sans-serif" font-weight="900" letter-spacing="2">BOMBER</text>
</g>
<text x="535" y="166" font-size="24" fill="#b6dfff" font-family="Verdana, sans-serif">GRID SHOWDOWN</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<ellipse cx="32" cy="58" rx="17" ry="4.6" fill="#000" opacity="0.24"/>
<g>
<rect x="24" y="4" width="16" height="5" rx="2" fill="#8fd7ff"/>
<rect x="30" y="0" width="4" height="7" rx="2" fill="#bde8ff"/>
<circle cx="32" cy="1.8" r="1.8" fill="#ffd76c"/>
</g>
<rect x="18" y="9" width="28" height="22" rx="5" fill="#d4e3ef" stroke="#7f94a8" stroke-width="2"/>
<rect x="21" y="15" width="22" height="9" rx="3" fill="#0e2337"/>
<circle cx="27" cy="19.5" r="2.2" fill="#6fe3ff"/>
<circle cx="37" cy="19.5" r="2.2" fill="#6fe3ff"/>
<rect x="28" y="25" width="8" height="2" rx="1" fill="#7f94a8"/>
<rect x="16" y="34" width="32" height="20" rx="7" fill="#2d8ddd"/>
<rect x="14" y="37" width="6" height="13" rx="2" fill="#4fb6ff"/>
<rect x="44" y="37" width="6" height="13" rx="2" fill="#4fb6ff"/>
<rect x="20" y="38" width="24" height="8" rx="3" fill="#7fd0ff" opacity="0.75"/>
<rect x="22" y="54" width="8" height="6" rx="2" fill="#b7c8d8"/>
<rect x="34" y="54" width="8" height="6" rx="2" fill="#b7c8d8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<ellipse cx="32" cy="58" rx="17" ry="4.6" fill="#000" opacity="0.24"/>
<g>
<rect x="24" y="4" width="16" height="5" rx="2" fill="#c9ffd9"/>
<rect x="30" y="0" width="4" height="7" rx="2" fill="#e1ffe9"/>
<circle cx="32" cy="1.8" r="1.8" fill="#ffd76c"/>
</g>
<rect x="18" y="9" width="28" height="22" rx="5" fill="#d7e5db" stroke="#78947f" stroke-width="2"/>
<rect x="21" y="15" width="22" height="9" rx="3" fill="#102c1c"/>
<circle cx="27" cy="19.5" r="2.2" fill="#a2ffd0"/>
<circle cx="37" cy="19.5" r="2.2" fill="#a2ffd0"/>
<rect x="28" y="25" width="8" height="2" rx="1" fill="#78947f"/>
<rect x="16" y="34" width="32" height="20" rx="7" fill="#4ec273"/>
<rect x="14" y="37" width="6" height="13" rx="2" fill="#7be39d"/>
<rect x="44" y="37" width="6" height="13" rx="2" fill="#7be39d"/>
<rect x="20" y="38" width="24" height="8" rx="3" fill="#b6f1ca" opacity="0.75"/>
<rect x="22" y="54" width="8" height="6" rx="2" fill="#bad3c1"/>
<rect x="34" y="54" width="8" height="6" rx="2" fill="#bad3c1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<ellipse cx="32" cy="58" rx="17" ry="4.6" fill="#000" opacity="0.24"/>
<g>
<rect x="24" y="4" width="16" height="5" rx="2" fill="#ffc0b6"/>
<rect x="30" y="0" width="4" height="7" rx="2" fill="#ffd7d0"/>
<circle cx="32" cy="1.8" r="1.8" fill="#ffd76c"/>
</g>
<rect x="18" y="9" width="28" height="22" rx="5" fill="#e4d7d4" stroke="#987774" stroke-width="2"/>
<rect x="21" y="15" width="22" height="9" rx="3" fill="#3a1210"/>
<circle cx="27" cy="19.5" r="2.2" fill="#ffb39f"/>
<circle cx="37" cy="19.5" r="2.2" fill="#ffb39f"/>
<rect x="28" y="25" width="8" height="2" rx="1" fill="#987774"/>
<rect x="16" y="34" width="32" height="20" rx="7" fill="#e45a4d"/>
<rect x="14" y="37" width="6" height="13" rx="2" fill="#ff8878"/>
<rect x="44" y="37" width="6" height="13" rx="2" fill="#ff8878"/>
<rect x="20" y="38" width="24" height="8" rx="3" fill="#ffb19f" opacity="0.75"/>
<rect x="22" y="54" width="8" height="6" rx="2" fill="#d7b5b1"/>
<rect x="34" y="54" width="8" height="6" rx="2" fill="#d7b5b1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<ellipse cx="32" cy="58" rx="17" ry="4.6" fill="#000" opacity="0.24"/>
<g>
<rect x="24" y="4" width="16" height="5" rx="2" fill="#ffeab4"/>
<rect x="30" y="0" width="4" height="7" rx="2" fill="#fff3d1"/>
<circle cx="32" cy="1.8" r="1.8" fill="#ffd76c"/>
</g>
<rect x="18" y="9" width="28" height="22" rx="5" fill="#e8dfcb" stroke="#998b68" stroke-width="2"/>
<rect x="21" y="15" width="22" height="9" rx="3" fill="#3c3013"/>
<circle cx="27" cy="19.5" r="2.2" fill="#ffe997"/>
<circle cx="37" cy="19.5" r="2.2" fill="#ffe997"/>
<rect x="28" y="25" width="8" height="2" rx="1" fill="#998b68"/>
<rect x="16" y="34" width="32" height="20" rx="7" fill="#ebbe3f"/>
<rect x="14" y="37" width="6" height="13" rx="2" fill="#ffd867"/>
<rect x="44" y="37" width="6" height="13" rx="2" fill="#ffd867"/>
<rect x="20" y="38" width="24" height="8" rx="3" fill="#ffe8a4" opacity="0.75"/>
<rect x="22" y="54" width="8" height="6" rx="2" fill="#d9cca7"/>
<rect x="34" y="54" width="8" height="6" rx="2" fill="#d9cca7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#1f3f5a"/>
<circle cx="32" cy="34" r="15" fill="#e4eef7"/>
<rect x="29" y="16" width="6" height="8" rx="2" fill="#e4eef7"/>
<path d="M32 20 C37 17 43 19 45 24" stroke="#e4eef7" stroke-width="2.6" fill="none"/>
<rect x="28" y="28" width="8" height="12" rx="2" fill="#0f2233"/>
<rect x="24" y="32" width="16" height="4" rx="2" fill="#0f2233"/>
</svg>

After

Width:  |  Height:  |  Size: 465 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#503025"/>
<path d="M32 10 C45 21 49 36 42 48 C37 57 27 57 22 48 C15 36 19 21 32 10Z" fill="#ff8f43"/>
<path d="M32 21 C38 27 39 35 36 41 C34 46 30 46 28 41 C25 35 26 27 32 21Z" fill="#fff7c7"/>
<rect x="28" y="44" width="8" height="12" rx="3" fill="#fff7c7"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#1b4b3d"/>
<path d="M12 33 H42" stroke="#d8ffe8" stroke-width="6" stroke-linecap="round"/>
<path d="M20 22 H50" stroke="#d8ffe8" stroke-width="6" stroke-linecap="round"/>
<path d="M8 44 H38" stroke="#d8ffe8" stroke-width="6" stroke-linecap="round"/>
<polygon points="40,16 58,32 40,48" fill="#a0ffd4"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

9
assets/tiles/bomb.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="none"/>
<circle cx="32" cy="36" r="20" fill="#111a25"/>
<ellipse cx="26" cy="30" rx="10" ry="7" fill="#5f7486" opacity="0.55"/>
<rect x="28" y="11" width="8" height="9" rx="3" fill="#cbd8e2"/>
<path d="M32 10 C38 6 45 8 47 14" stroke="#d8e4ee" stroke-width="3" fill="none"/>
<circle cx="49" cy="14" r="5" fill="#ff9059"/>
<path d="M49 8 C55 9 56 15 50 17 C52 14 51 11 49 8Z" fill="#ffe27a"/>
</svg>

After

Width:  |  Height:  |  Size: 509 B

12
assets/tiles/crate.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#c69057"/>
<stop offset="1" stop-color="#8f5f34"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="8" fill="#4f3218"/>
<rect x="5" y="5" width="54" height="54" rx="7" fill="url(#wood)"/>
<path d="M9 12H55M9 52H55M12 9V55M52 9V55" stroke="#6f4523" stroke-width="4"/>
<path d="M16 16 L48 48M48 16 L16 48" stroke="#e5bd83" stroke-width="3" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<radialGradient id="g" cx="0.5" cy="0.5" r="0.55">
<stop offset="0" stop-color="#fff7bf"/>
<stop offset="0.5" stop-color="#ffb347"/>
<stop offset="1" stop-color="#ff5a36"/>
</radialGradient>
<circle cx="32" cy="32" r="22" fill="url(#g)"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff5c8"/>
<stop offset="0.5" stop-color="#ffbe5b"/>
<stop offset="1" stop-color="#ff6d3a"/>
</linearGradient>
</defs>
<path d="M32 8 C44 18 48 31 45 42 C42 53 22 53 19 42 C16 31 20 18 32 8Z" fill="url(#f)"/>
<path d="M32 18 C38 24 39 32 37 37 C35 42 29 42 27 37 C25 32 26 24 32 18Z" fill="#fffad9" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="h" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ff7d3b"/>
<stop offset="0.5" stop-color="#ffd56a"/>
<stop offset="1" stop-color="#ff7d3b"/>
</linearGradient>
</defs>
<rect x="0" y="20" width="64" height="24" rx="10" fill="url(#h)"/>
<rect x="8" y="25" width="48" height="14" rx="7" fill="#fff4bd" opacity="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 457 B

8
assets/tiles/floor.svg Normal file
View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#263b4d"/>
<rect x="0" y="0" width="32" height="32" fill="#30495f"/>
<rect x="32" y="32" width="32" height="32" fill="#30495f"/>
<path d="M0 32H64M32 0V64" stroke="#3a5870" stroke-width="2" opacity="0.65"/>
<circle cx="8" cy="8" r="1.6" fill="#9ec9e8" opacity="0.45"/>
<circle cx="56" cy="56" r="1.6" fill="#9ec9e8" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

12
assets/tiles/wall.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8cb0c9"/>
<stop offset="1" stop-color="#3f5d74"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="8" fill="#223647"/>
<rect x="5" y="5" width="54" height="54" rx="6" fill="url(#metal)"/>
<rect x="13" y="13" width="38" height="38" rx="5" fill="#c8deee" opacity="0.45"/>
<path d="M12 24H52M12 40H52M24 12V52M40 12V52" stroke="#233948" stroke-width="2" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

19
index.html Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GPT Bomber</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="page-bg"></div>
<main class="layout">
<section class="game-shell">
<canvas id="game" width="720" height="624" aria-label="GPT Bomber game board"></canvas>
</section>
</main>
<script type="module" src="src/game.js"></script>
</body>
</html>

36
package-lock.json generated Normal file
View File

@ -0,0 +1,36 @@
{
"name": "gpt-bomber",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gpt-bomber",
"version": "1.0.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "gpt-bomber",
"version": "1.0.0",
"private": true,
"description": "GPT Bomber browser game with internet multiplayer backend",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "^8.18.0"
}
}

328
server.js Normal file
View File

@ -0,0 +1,328 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { WebSocketServer } = require("ws");
const PORT = Number(process.env.PORT || 8080);
const MAX_PLAYERS = 4;
const ROOT = __dirname;
const CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".ico": "image/x-icon",
};
const clientsBySocket = new Map();
const clientsById = new Map();
const lobby = {
phase: "lobby", // lobby | game
players: [], // [{id,name,joinedAt,lastSeenAt}]
hostId: null,
roster: [], // active game roster [{id,name,joinedAt}]
};
function now() {
return Date.now();
}
function makeId() {
return crypto.randomUUID ? crypto.randomUUID() : `${now()}-${Math.floor(Math.random() * 1e6)}`;
}
function defaultName(id) {
return `P-${id.slice(0, 4).toUpperCase()}`;
}
function normalizeName(value, fallback) {
if (typeof value !== "string") {
return fallback;
}
const normalized = value
.replace(/\s+/g, " ")
.trim()
.slice(0, 20);
return normalized || fallback;
}
function safePath(urlPath) {
const clean = decodeURIComponent(urlPath.split("?")[0]);
const target = clean === "/" ? "/index.html" : clean;
const resolved = path.resolve(ROOT, `.${target}`);
if (!resolved.startsWith(ROOT)) {
return null;
}
return resolved;
}
function send(ws, payload) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function broadcast(payload, filterFn = null) {
for (const [ws, client] of clientsBySocket.entries()) {
if (filterFn && !filterFn(client)) {
continue;
}
send(ws, payload);
}
}
function normalizePlayers(players) {
return [...players]
.sort((a, b) => a.joinedAt - b.joinedAt)
.map((p) => ({ id: p.id, name: p.name, joinedAt: p.joinedAt }));
}
function ensureHost() {
if (lobby.players.length === 0) {
lobby.hostId = null;
return;
}
const hasHost = lobby.players.some((p) => p.id === lobby.hostId);
if (!hasHost) {
lobby.hostId = normalizePlayers(lobby.players)[0].id;
}
}
function broadcastLobbyState(reason = null) {
ensureHost();
broadcast({
type: "lobby_state",
phase: lobby.phase,
hostId: lobby.hostId,
players: normalizePlayers(lobby.players),
reason,
});
}
function endMatch(reason = "Match ended") {
lobby.phase = "lobby";
lobby.roster = [];
broadcastLobbyState(reason);
}
function startMatch() {
const ordered = normalizePlayers(lobby.players);
if (ordered.length < 1) {
return;
}
lobby.phase = "game";
lobby.roster = ordered.slice(0, MAX_PLAYERS);
broadcast({
type: "lobby_start",
hostId: lobby.hostId,
roster: lobby.roster,
});
}
function removePlayer(clientId) {
lobby.players = lobby.players.filter((p) => p.id !== clientId);
if (lobby.phase === "game") {
const inRoster = lobby.roster.some((p) => p.id === clientId);
if (inRoster) {
endMatch("A player disconnected. Back to lobby.");
return;
}
}
ensureHost();
broadcastLobbyState();
}
function addOrRefreshPlayer(client) {
const existing = lobby.players.find((p) => p.id === client.id);
if (existing) {
existing.lastSeenAt = now();
existing.name = client.name;
return;
}
lobby.players.push({
id: client.id,
name: client.name,
joinedAt: now(),
lastSeenAt: now(),
});
ensureHost();
}
function handleMessage(client, msg) {
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
return;
}
if (msg.type === "hello") {
client.name = normalizeName(msg.name, client.name);
send(client.ws, { type: "welcome", clientId: client.id, name: client.name });
return;
}
if (msg.type === "set_name") {
client.name = normalizeName(msg.name, client.name);
const existing = lobby.players.find((p) => p.id === client.id);
if (existing) {
existing.name = client.name;
existing.lastSeenAt = now();
if (lobby.phase === "game") {
const inRoster = lobby.roster.find((p) => p.id === client.id);
if (inRoster) {
inRoster.name = client.name;
}
}
broadcastLobbyState();
}
send(client.ws, { type: "welcome", clientId: client.id, name: client.name });
return;
}
if (msg.type === "lobby_join") {
addOrRefreshPlayer(client);
broadcastLobbyState();
return;
}
if (msg.type === "lobby_leave") {
removePlayer(client.id);
return;
}
if (msg.type === "lobby_start") {
if (lobby.phase !== "lobby") {
return;
}
if (client.id !== lobby.hostId) {
send(client.ws, { type: "error", message: "Only the host can start the match." });
return;
}
startMatch();
return;
}
if (msg.type === "player_input") {
if (lobby.phase !== "game") {
return;
}
if (client.id === lobby.hostId) {
return;
}
const hostClient = clientsById.get(lobby.hostId);
if (!hostClient) {
return;
}
send(hostClient.ws, {
type: "player_input",
playerId: client.id,
input: msg.input || { dir: null, bomb: false },
ts: now(),
});
return;
}
if (msg.type === "game_snapshot") {
if (lobby.phase !== "game") {
return;
}
if (client.id !== lobby.hostId) {
return;
}
const rosterIds = new Set(lobby.roster.map((p) => p.id));
broadcast(
{
type: "game_snapshot",
snapshot: msg.snapshot,
ts: now(),
},
(target) => rosterIds.has(target.id) && target.id !== lobby.hostId,
);
return;
}
if (msg.type === "ping") {
addOrRefreshPlayer(client);
}
}
const server = http.createServer((req, res) => {
const filePath = safePath(req.url || "/");
if (!filePath) {
res.writeHead(403).end("Forbidden");
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found");
return;
}
const ext = path.extname(filePath).toLowerCase();
const type = CONTENT_TYPES[ext] || "application/octet-stream";
res.writeHead(200, { "Content-Type": type });
res.end(data);
});
});
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", (ws) => {
const id = makeId();
const client = {
id,
name: defaultName(id),
ws,
};
clientsBySocket.set(ws, client);
clientsById.set(client.id, client);
send(ws, { type: "welcome", clientId: client.id, name: client.name });
ws.on("message", (data) => {
let msg;
try {
msg = JSON.parse(data.toString());
} catch {
return;
}
handleMessage(client, msg);
});
ws.on("close", () => {
clientsBySocket.delete(ws);
clientsById.delete(client.id);
removePlayer(client.id);
});
ws.on("error", () => {
// no-op
});
});
server.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`GPT Bomber server running on http://0.0.0.0:${PORT}`);
});

2405
src/game.js Normal file

File diff suppressed because it is too large Load Diff

75
styles.css Normal file
View File

@ -0,0 +1,75 @@
:root {
--bg-1: #08131d;
--bg-2: #14293a;
--panel: #102132cc;
--panel-border: #6ec5ff66;
--text: #ecf6ff;
--accent: #ffd166;
--danger: #ff6b6b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
color: var(--text);
background: linear-gradient(145deg, var(--bg-1), var(--bg-2));
position: relative;
overflow: hidden;
}
.page-bg {
position: fixed;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(circle at 12% 18%, #3f95d433 0%, transparent 30%),
radial-gradient(circle at 84% 80%, #ffd1662c 0%, transparent 28%),
repeating-linear-gradient(
0deg,
transparent,
transparent 36px,
#ffffff08 36px,
#ffffff08 37px
);
}
.layout {
width: 100%;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
padding: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.game-shell {
margin: 0;
position: relative;
background: #0f1c2a;
border: 2px solid var(--panel-border);
border-radius: 0;
box-shadow: 0 10px 24px #00000055;
width: 100vw;
height: 100vh;
height: 100dvh;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
display: block;
width: min(100vw, calc(100dvh * 15 / 13));
height: min(100dvh, calc(100vw * 13 / 15));
aspect-ratio: 15 / 13;
}