Initial commit
42
README.md
@ -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.
|
||||||
|
|||||||
41
assets/logo/neon-bomber.svg
Normal 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 |
19
assets/players/player-blue.svg
Normal 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 |
19
assets/players/player-green.svg
Normal 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 |
19
assets/players/player-red.svg
Normal 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 |
19
assets/players/player-yellow.svg
Normal 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 |
8
assets/powerups/bomb-up.svg
Normal 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 |
6
assets/powerups/flame-up.svg
Normal 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 |
7
assets/powerups/speed-up.svg
Normal 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
@ -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
@ -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 |
8
assets/tiles/flame-center.svg
Normal 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 |
11
assets/tiles/flame-end.svg
Normal 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 |
11
assets/tiles/flame-straight.svg
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
75
styles.css
Normal 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;
|
||||||
|
}
|
||||||