Abhinay Claude Opus 4.7 (1M context) commited on
Commit
024703b
·
0 Parent(s):

Initial commit — egg-catcher arcade

Browse files

Pixel-art catch-the-eggs game with:
- Laptop view with full-screen canvas + QR pop-up for phone controller
- Mobile standalone mode with on-screen touch buttons
- Phone controller (paired via QR over LAN in dev, over Fly domain in prod)
- 60s rounds with a draining vertical time tube (no seconds counter)
- Missed eggs splat into omelets that random cows wander in to eat
- MongoDB-backed global leaderboard, user keyed by lowercased username
- Express + SSE relay + Vite frontend, single-server deploy
- Fly.io ready (Dockerfile + fly.toml)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (18) hide show
  1. .dockerignore +11 -0
  2. .gitignore +5 -0
  3. Dockerfile +18 -0
  4. README.md +44 -0
  5. fly.toml +28 -0
  6. index.html +15 -0
  7. package-lock.json +0 -0
  8. package.json +27 -0
  9. server/server.js +292 -0
  10. src/App.jsx +140 -0
  11. src/Controller.jsx +198 -0
  12. src/Game.jsx +597 -0
  13. src/Leaderboard.jsx +121 -0
  14. src/MobileGame.jsx +442 -0
  15. src/UsernameGate.jsx +74 -0
  16. src/main.jsx +14 -0
  17. src/styles.css +1242 -0
  18. vite.config.js +17 -0
.dockerignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ .git
4
+ .gitignore
5
+ .env
6
+ .env.*
7
+ *.log
8
+ README.md
9
+ .DS_Store
10
+ .vscode
11
+ .idea
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ .env
4
+ .DS_Store
5
+ *.log
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ FROM node:20-alpine AS build
3
+ WORKDIR /app
4
+ COPY package.json package-lock.json* ./
5
+ RUN npm install --no-audit --no-fund
6
+ COPY . .
7
+ RUN npm run build
8
+
9
+ FROM node:20-alpine AS runtime
10
+ WORKDIR /app
11
+ ENV NODE_ENV=production
12
+ COPY package.json package-lock.json* ./
13
+ RUN npm install --omit=dev --no-audit --no-fund
14
+ COPY --from=build /app/dist ./dist
15
+ COPY server ./server
16
+ EXPOSE 8080
17
+ ENV PORT=8080
18
+ CMD ["node", "server/server.js"]
README.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # egg-catcher
2
+
3
+ Pixel-art egg-catcher arcade. Laptop = main screen with a QR pop-up; phone = controller. MongoDB-backed global leaderboard.
4
+
5
+ ## Run locally
6
+
7
+ ```bash
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ `http://localhost:5173/` on the laptop. Phone scans the QR (same wifi).
13
+
14
+ `.env` (already gitignored) needs:
15
+
16
+ ```
17
+ MONGO_URI=mongodb+srv://...
18
+ MONGO_DB=news
19
+ MONGO_COLLECTION=egg_catcher_scores
20
+ ```
21
+
22
+ ## Deploy to Fly.io
23
+
24
+ One-time:
25
+
26
+ ```bash
27
+ flyctl auth login
28
+ flyctl launch --no-deploy # accept fly.toml, pick an app name
29
+ flyctl secrets set MONGO_URI="mongodb+srv://abhinayabhi226_db_user:Od1IwrzfJbXybXap@cluster0.ctylbok.mongodb.net/news?retryWrites=true&w=majority"
30
+ flyctl deploy
31
+ ```
32
+
33
+ Subsequent deploys:
34
+
35
+ ```bash
36
+ flyctl deploy
37
+ ```
38
+
39
+ ## How it works
40
+
41
+ - Single Express server: serves the built Vite frontend AND the API.
42
+ - Game rooms are in-memory; SSE relays controller events laptop ⇄ phone.
43
+ - `/api/score`, `/api/leaderboard` write to MongoDB collection `egg_catcher_scores`.
44
+ - In production the controller URL is `https://<your-app>.fly.dev/controller?room=XXXXXX`. In dev it's `http://<LAN-IP>:5173/controller?room=XXXXXX`.
fly.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fly.toml — change `app` to whatever name you registered with `fly launch`
2
+ app = "egg-catcher"
3
+ primary_region = "bom" # Mumbai — change to whatever's near you
4
+
5
+ [build]
6
+
7
+ [env]
8
+ NODE_ENV = "production"
9
+ MONGO_DB = "news"
10
+ MONGO_COLLECTION = "egg_catcher_scores"
11
+
12
+ [http_service]
13
+ internal_port = 8080
14
+ force_https = true
15
+ auto_stop_machines = "stop"
16
+ auto_start_machines = true
17
+ min_machines_running = 0
18
+ processes = ["app"]
19
+
20
+ [http_service.concurrency]
21
+ type = "requests"
22
+ hard_limit = 200
23
+ soft_limit = 150
24
+
25
+ [[vm]]
26
+ cpu_kind = "shared"
27
+ cpus = 1
28
+ memory_mb = 256
index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
6
+ <title>Egg Catcher — Arcade</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet" />
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.jsx"></script>
14
+ </body>
15
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "egg-catcher-game",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "concurrently -n vite,api -c blue,magenta \"npm:dev:vite\" \"npm:server\"",
8
+ "dev:vite": "vite",
9
+ "server": "node server/server.js",
10
+ "build": "vite build",
11
+ "start": "node server/server.js",
12
+ "preview": "vite preview"
13
+ },
14
+ "dependencies": {
15
+ "cors": "^2.8.5",
16
+ "dotenv": "^16.4.5",
17
+ "express": "^4.21.1",
18
+ "mongodb": "^6.10.0",
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@vitejs/plugin-react": "^4.3.3",
24
+ "concurrently": "^9.0.1",
25
+ "vite": "^5.4.10"
26
+ }
27
+ }
server/server.js ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+ import express from 'express';
3
+ import cors from 'cors';
4
+ import { networkInterfaces } from 'os';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+ import { existsSync } from 'fs';
8
+ import { MongoClient } from 'mongodb';
9
+
10
+ const {
11
+ MONGO_URI,
12
+ MONGO_DB = 'news',
13
+ MONGO_COLLECTION = 'egg_catcher_scores',
14
+ } = process.env;
15
+
16
+ const PORT = process.env.PORT || process.env.API_PORT || 5174;
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const DIST_DIR = join(__dirname, '..', 'dist');
19
+ const SERVE_STATIC = existsSync(DIST_DIR);
20
+
21
+ if (!MONGO_URI) {
22
+ console.error('[egg-catcher] MONGO_URI is missing from .env');
23
+ process.exit(1);
24
+ }
25
+
26
+ const mongo = new MongoClient(MONGO_URI, { serverSelectionTimeoutMS: 8000 });
27
+ let scores;
28
+
29
+ async function connectMongo() {
30
+ await mongo.connect();
31
+ const db = mongo.db(MONGO_DB);
32
+ scores = db.collection(MONGO_COLLECTION);
33
+ await scores.createIndex({ username_lower: 1 }, { unique: true });
34
+ await scores.createIndex({ best: -1 });
35
+ console.log(`[egg-catcher] mongo connected — ${MONGO_DB}.${MONGO_COLLECTION}`);
36
+ }
37
+
38
+ const app = express();
39
+ app.use(cors());
40
+ app.use(express.json({ limit: '32kb' }));
41
+
42
+ /* ------- helpers ------- */
43
+
44
+ const USERNAME_RE = /^[A-Za-z0-9_\-]{2,16}$/;
45
+
46
+ function cleanUsername(raw) {
47
+ return String(raw || '').trim();
48
+ }
49
+
50
+ /* ------- leaderboard endpoints ------- */
51
+
52
+ app.post('/api/user/check', async (req, res) => {
53
+ const username = cleanUsername(req.body?.username);
54
+ if (!USERNAME_RE.test(username)) {
55
+ return res.status(400).json({ ok: false, error: 'Username must be 2-16 letters, numbers, _ or -' });
56
+ }
57
+ const existing = await scores.findOne({ username_lower: username.toLowerCase() });
58
+ res.json({ ok: true, exists: !!existing, best: existing?.best || 0 });
59
+ });
60
+
61
+ app.post('/api/score', async (req, res) => {
62
+ const username = cleanUsername(req.body?.username);
63
+ const score = Math.max(0, Math.min(1_000_000, Number(req.body?.score) || 0));
64
+ if (!USERNAME_RE.test(username)) {
65
+ return res.status(400).json({ ok: false, error: 'invalid username' });
66
+ }
67
+ const key = username.toLowerCase();
68
+ const now = new Date();
69
+ const existing = await scores.findOne({ username_lower: key });
70
+
71
+ if (!existing) {
72
+ await scores.insertOne({
73
+ username,
74
+ username_lower: key,
75
+ best: score,
76
+ lastScore: score,
77
+ games: 1,
78
+ createdAt: now,
79
+ updatedAt: now,
80
+ });
81
+ } else {
82
+ await scores.updateOne(
83
+ { username_lower: key },
84
+ {
85
+ $set: {
86
+ username,
87
+ lastScore: score,
88
+ updatedAt: now,
89
+ ...(score > (existing.best || 0) ? { best: score } : {}),
90
+ },
91
+ $inc: { games: 1 },
92
+ },
93
+ );
94
+ }
95
+
96
+ const updated = await scores.findOne({ username_lower: key });
97
+ const rank = (await scores.countDocuments({ best: { $gt: updated.best } })) + 1;
98
+ const total = await scores.countDocuments({});
99
+ res.json({ ok: true, rank, total, best: updated.best, lastScore: updated.lastScore });
100
+ });
101
+
102
+ app.get('/api/leaderboard', async (req, res) => {
103
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25));
104
+ const username = cleanUsername(req.query.username);
105
+ const top = await scores
106
+ .find({}, { projection: { _id: 0, username: 1, best: 1, games: 1, updatedAt: 1 } })
107
+ .sort({ best: -1, updatedAt: 1 })
108
+ .limit(limit)
109
+ .toArray();
110
+
111
+ let me = null;
112
+ if (USERNAME_RE.test(username)) {
113
+ const entry = await scores.findOne({ username_lower: username.toLowerCase() });
114
+ if (entry) {
115
+ const rank = (await scores.countDocuments({ best: { $gt: entry.best } })) + 1;
116
+ me = {
117
+ username: entry.username,
118
+ best: entry.best,
119
+ games: entry.games,
120
+ rank,
121
+ };
122
+ }
123
+ }
124
+
125
+ const total = await scores.countDocuments({});
126
+ res.json({ ok: true, top, total, me });
127
+ });
128
+
129
+ app.get('/api/health', async (_req, res) => {
130
+ try {
131
+ const count = await scores.estimatedDocumentCount();
132
+ res.json({ ok: true, mongo: 'up', count });
133
+ } catch (err) {
134
+ res.status(500).json({ ok: false, error: String(err) });
135
+ }
136
+ });
137
+
138
+ /* ============================================================
139
+ GAME ROOMS — SSE relay between laptop and phone controller
140
+ ============================================================ */
141
+
142
+ const ROOM_TTL_MS = 1000 * 60 * 30;
143
+ const rooms = new Map();
144
+
145
+ function newRoomId() {
146
+ const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
147
+ let id = '';
148
+ for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)];
149
+ return id;
150
+ }
151
+
152
+ function pruneRooms() {
153
+ const now = Date.now();
154
+ for (const [id, room] of rooms) {
155
+ if (now - room.createdAt > ROOM_TTL_MS && !room.game && room.controllers.size === 0) {
156
+ rooms.delete(id);
157
+ }
158
+ }
159
+ }
160
+ setInterval(pruneRooms, 60_000).unref?.();
161
+
162
+ function getLocalIps() {
163
+ const nets = networkInterfaces();
164
+ const out = [];
165
+ for (const name of Object.keys(nets)) {
166
+ for (const net of nets[name] || []) {
167
+ if (net.family === 'IPv4' && !net.internal) out.push({ name, address: net.address });
168
+ }
169
+ }
170
+ const isVirtual = (n) => /vethernet|hyper-v|wsl|virtualbox|vmware|loopback|docker|local area connection\*/i.test(n);
171
+ const isWifi = (n) => /wi-?fi|wlan|wireless/i.test(n);
172
+ const isLan = (a) => /^(192\.168|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a);
173
+ const score = (c) => {
174
+ let s = 0;
175
+ if (isVirtual(c.name)) s += 100;
176
+ if (!isLan(c.address)) s += 10;
177
+ if (isWifi(c.name)) s -= 5;
178
+ return s;
179
+ };
180
+ out.sort((a, b) => score(a) - score(b));
181
+ return out;
182
+ }
183
+
184
+ app.get('/api/game/local-ip', (_req, res) => {
185
+ const ips = getLocalIps();
186
+ res.json({ ip: ips[0]?.address || 'localhost', candidates: ips });
187
+ });
188
+
189
+ app.post('/api/game/room', (_req, res) => {
190
+ const id = newRoomId();
191
+ rooms.set(id, { game: null, controllers: new Set(), createdAt: Date.now(), username: null });
192
+ res.json({ roomId: id });
193
+ });
194
+
195
+ function sseHeaders(res) {
196
+ res.set({
197
+ 'Content-Type': 'text/event-stream',
198
+ 'Cache-Control': 'no-cache, no-transform',
199
+ Connection: 'keep-alive',
200
+ 'X-Accel-Buffering': 'no',
201
+ });
202
+ res.flushHeaders?.();
203
+ }
204
+
205
+ app.get('/api/game/events/:roomId', (req, res) => {
206
+ const { roomId } = req.params;
207
+ let room = rooms.get(roomId);
208
+ if (!room) {
209
+ room = { game: null, controllers: new Set(), createdAt: Date.now(), username: null };
210
+ rooms.set(roomId, room);
211
+ }
212
+ sseHeaders(res);
213
+ room.game = res;
214
+ res.write(`event: ready\ndata: ${JSON.stringify({ roomId, paired: room.controllers.size > 0 })}\n\n`);
215
+ for (const c of room.controllers) c.write(`event: paired\ndata: {}\n\n`);
216
+
217
+ const ping = setInterval(() => {
218
+ try { res.write(`: ping\n\n`); } catch {}
219
+ }, 15_000);
220
+
221
+ req.on('close', () => {
222
+ clearInterval(ping);
223
+ if (room.game === res) room.game = null;
224
+ });
225
+ });
226
+
227
+ app.get('/api/game/controller-events/:roomId', (req, res) => {
228
+ const { roomId } = req.params;
229
+ const room = rooms.get(roomId);
230
+ if (!room) return res.status(404).end();
231
+ sseHeaders(res);
232
+ room.controllers.add(res);
233
+ res.write(`event: ready\ndata: {"paired":${room.game ? 'true' : 'false'}}\n\n`);
234
+
235
+ const ping = setInterval(() => {
236
+ try { res.write(`: ping\n\n`); } catch {}
237
+ }, 15_000);
238
+
239
+ req.on('close', () => {
240
+ clearInterval(ping);
241
+ room.controllers.delete(res);
242
+ });
243
+ });
244
+
245
+ app.post('/api/game/control', (req, res) => {
246
+ const { roomId, action } = req.body || {};
247
+ const room = rooms.get(roomId);
248
+ if (!room) return res.status(404).json({ error: 'room not found' });
249
+ if (!room.game) return res.status(409).json({ error: 'game not connected' });
250
+ const safe = String(action || '').slice(0, 32);
251
+ try {
252
+ room.game.write(`event: control\ndata: ${JSON.stringify({ action: safe })}\n\n`);
253
+ } catch {}
254
+ res.json({ ok: true });
255
+ });
256
+
257
+ app.post('/api/game/state', (req, res) => {
258
+ const { roomId, ...state } = req.body || {};
259
+ const room = rooms.get(roomId);
260
+ if (!room) return res.status(404).json({ error: 'room not found' });
261
+ const payload = JSON.stringify(state).slice(0, 512);
262
+ for (const c of room.controllers) {
263
+ try { c.write(`event: state\ndata: ${payload}\n\n`); } catch {}
264
+ }
265
+ res.json({ ok: true });
266
+ });
267
+
268
+ /* ------- start ------- */
269
+
270
+ // ------- static frontend (production) -------
271
+
272
+ if (SERVE_STATIC) {
273
+ app.use(express.static(DIST_DIR));
274
+ app.get(/^\/(?!api\/).*/, (_req, res) => {
275
+ res.sendFile(join(DIST_DIR, 'index.html'));
276
+ });
277
+ console.log(`[egg-catcher] serving static frontend from ${DIST_DIR}`);
278
+ }
279
+
280
+ connectMongo()
281
+ .then(() => {
282
+ app.listen(PORT, '0.0.0.0', () => {
283
+ console.log(`[egg-catcher] listening on http://0.0.0.0:${PORT}`);
284
+ if (!SERVE_STATIC) {
285
+ console.log(`[egg-catcher] LAN ips: ${getLocalIps().map((c) => `${c.address} (${c.name})`).join(', ')}`);
286
+ }
287
+ });
288
+ })
289
+ .catch((err) => {
290
+ console.error('[egg-catcher] mongo connect failed:', err.message);
291
+ process.exit(1);
292
+ });
src/App.jsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import UsernameGate from './UsernameGate.jsx';
3
+ import Game from './Game.jsx';
4
+ import MobileGame from './MobileGame.jsx';
5
+ import Leaderboard from './Leaderboard.jsx';
6
+
7
+ function detectMobile() {
8
+ if (typeof window === 'undefined') return false;
9
+ const ua = navigator.userAgent || '';
10
+ const isTouch = (navigator.maxTouchPoints || 0) > 0;
11
+ const isPhoneUA = /Android|iPhone|iPod|Mobile/i.test(ua);
12
+ const isSmall = window.innerWidth < 820;
13
+ return isPhoneUA || (isTouch && isSmall);
14
+ }
15
+
16
+ export default function App() {
17
+ const [username, setUsername] = React.useState(() => {
18
+ try { return localStorage.getItem('egg-username') || ''; } catch { return ''; }
19
+ });
20
+ const [mode, setMode] = React.useState(() => detectMobile() ? 'mobile' : 'desktop');
21
+ const [view, setView] = React.useState('play');
22
+ const [lastResult, setLastResult] = React.useState(null);
23
+
24
+ // room hoisted here so Play Again reuses the same room (controller stays paired)
25
+ const [roomId, setRoomId] = React.useState(null);
26
+ const [lanIp, setLanIp] = React.useState(null);
27
+ const [ipCandidates, setIpCandidates] = React.useState([]);
28
+
29
+ React.useEffect(() => {
30
+ const onResize = () => setMode(detectMobile() ? 'mobile' : 'desktop');
31
+ window.addEventListener('resize', onResize);
32
+ return () => window.removeEventListener('resize', onResize);
33
+ }, []);
34
+
35
+ React.useEffect(() => {
36
+ if (mode !== 'desktop' || roomId) return;
37
+ let alive = true;
38
+ (async () => {
39
+ try {
40
+ const r1 = await fetch('/api/game/room', { method: 'POST' }).then((r) => r.json());
41
+ if (!alive) return;
42
+ setRoomId(r1.roomId);
43
+ if (import.meta.env.PROD) return; // in prod, controller URL = window.location.origin
44
+ try {
45
+ const r2 = await fetch('/api/game/local-ip').then((r) => r.json());
46
+ if (!alive) return;
47
+ setLanIp(r2.ip);
48
+ setIpCandidates(Array.isArray(r2.candidates) ? r2.candidates : []);
49
+ } catch {}
50
+ } catch {}
51
+ })();
52
+ return () => { alive = false; };
53
+ }, [mode, roomId]);
54
+
55
+ const onPickUsername = (name) => {
56
+ try { localStorage.setItem('egg-username', name); } catch {}
57
+ setUsername(name);
58
+ };
59
+
60
+ const onGameOver = (result) => {
61
+ setLastResult(result);
62
+ setView('board');
63
+ };
64
+
65
+ const playAgain = () => {
66
+ setLastResult(null);
67
+ setView('play');
68
+ };
69
+
70
+ const changeUser = () => {
71
+ try { localStorage.removeItem('egg-username'); } catch {}
72
+ setUsername('');
73
+ setLastResult(null);
74
+ setView('play');
75
+ try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
76
+ };
77
+
78
+ if (!username) {
79
+ return <UsernameGate onPick={onPickUsername} isMobile={mode === 'mobile'} />;
80
+ }
81
+
82
+ if (view === 'board') {
83
+ return (
84
+ <Leaderboard
85
+ username={username}
86
+ lastResult={lastResult}
87
+ onPlayAgain={playAgain}
88
+ onChangeUser={changeUser}
89
+ />
90
+ );
91
+ }
92
+
93
+ return (
94
+ <div className="app-shell">
95
+ <TopBar
96
+ username={username}
97
+ mode={mode}
98
+ onSeeBoard={() => setView('board')}
99
+ onChangeUser={changeUser}
100
+ onToggleMode={() => setMode((m) => (m === 'mobile' ? 'desktop' : 'mobile'))}
101
+ />
102
+ {mode === 'mobile' ? (
103
+ <MobileGame username={username} onGameOver={onGameOver} />
104
+ ) : (
105
+ <Game
106
+ username={username}
107
+ onGameOver={onGameOver}
108
+ roomId={roomId}
109
+ lanIp={lanIp}
110
+ ipCandidates={ipCandidates}
111
+ onSetLanIp={setLanIp}
112
+ />
113
+ )}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ function TopBar({ username, mode, onSeeBoard, onChangeUser, onToggleMode }) {
119
+ return (
120
+ <header className="topbar">
121
+ <div className="topbar__brand">
122
+ <span className="topbar__logo" aria-hidden="true">
123
+ <span>E</span><span>G</span><span>G</span>
124
+ </span>
125
+ <span className="topbar__title">EGG-CATCHER</span>
126
+ </div>
127
+ <div className="topbar__user">
128
+ <span className="topbar__userTag">▸ PLAYER</span>
129
+ <span className="topbar__userName">{username}</span>
130
+ </div>
131
+ <div className="topbar__actions">
132
+ <button className="tbtn tbtn--alt" onClick={onToggleMode} title="Switch device mode">
133
+ {mode === 'mobile' ? '▣ DESKTOP' : '▢ MOBILE'}
134
+ </button>
135
+ <button className="tbtn" onClick={onSeeBoard}>★ LEADERBOARD</button>
136
+ <button className="tbtn tbtn--ghost" onClick={onChangeUser}>↺ NEW USER</button>
137
+ </div>
138
+ </header>
139
+ );
140
+ }
src/Controller.jsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const ROUND_MS = 60_000;
4
+
5
+ function send(roomId, action) {
6
+ fetch('/api/game/control', {
7
+ method: 'POST',
8
+ headers: { 'Content-Type': 'application/json' },
9
+ body: JSON.stringify({ roomId, action }),
10
+ }).catch(() => {});
11
+ }
12
+
13
+ function isLandscape() {
14
+ if (typeof window === 'undefined') return true;
15
+ return window.matchMedia('(orientation: landscape)').matches;
16
+ }
17
+
18
+ export default function Controller() {
19
+ const params = new URLSearchParams(window.location.search);
20
+ const roomId = (params.get('room') || '').toUpperCase();
21
+
22
+ const [paired, setPaired] = React.useState(false);
23
+ const [score, setScore] = React.useState(0);
24
+ const [timeLeft, setTimeLeft] = React.useState(null);
25
+ const [running, setRunning] = React.useState(false);
26
+ const [landscape, setLandscape] = React.useState(isLandscape());
27
+ const [armed, setArmed] = React.useState(false);
28
+
29
+ React.useEffect(() => {
30
+ if (!roomId) return;
31
+ const es = new EventSource(`/api/game/controller-events/${roomId}`);
32
+ es.addEventListener('ready', (e) => {
33
+ try { setPaired(JSON.parse(e.data).paired); } catch {}
34
+ });
35
+ es.addEventListener('paired', () => setPaired(true));
36
+ es.addEventListener('state', (e) => {
37
+ try {
38
+ const s = JSON.parse(e.data);
39
+ if (typeof s.score === 'number') setScore(s.score);
40
+ if (typeof s.timeLeft === 'number') setTimeLeft(s.timeLeft);
41
+ if (typeof s.running === 'boolean') setRunning(s.running);
42
+ } catch {}
43
+ });
44
+ es.onerror = () => {};
45
+ return () => es.close();
46
+ }, [roomId]);
47
+
48
+ React.useEffect(() => {
49
+ const prev = document.body.style.overscrollBehavior;
50
+ document.body.style.overscrollBehavior = 'none';
51
+ document.body.classList.add('controller-body');
52
+ return () => {
53
+ document.body.style.overscrollBehavior = prev;
54
+ document.body.classList.remove('controller-body');
55
+ };
56
+ }, []);
57
+
58
+ React.useEffect(() => {
59
+ const mq = window.matchMedia('(orientation: landscape)');
60
+ const update = () => setLandscape(mq.matches);
61
+ mq.addEventListener?.('change', update);
62
+ window.addEventListener('resize', update);
63
+ return () => {
64
+ mq.removeEventListener?.('change', update);
65
+ window.removeEventListener('resize', update);
66
+ };
67
+ }, []);
68
+
69
+ const arm = React.useCallback(async () => {
70
+ if (armed) return;
71
+ setArmed(true);
72
+ try { await document.documentElement.requestFullscreen?.(); } catch {}
73
+ try { await screen.orientation?.lock?.('landscape'); } catch {}
74
+ }, [armed]);
75
+
76
+ if (!roomId) {
77
+ return (
78
+ <div className="ctrl">
79
+ <div className="ctrl__panel">
80
+ <div className="ctrl__title">No room.</div>
81
+ <p className="ctrl__copy">
82
+ Open this page by scanning the QR code shown on the laptop.
83
+ </p>
84
+ </div>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ if (!armed) {
90
+ return (
91
+ <div className="ctrl ctrl--prep" onPointerDown={arm}>
92
+ <div className="ctrl__prep">
93
+ <div className="ctrl__prepRoom">ROOM {roomId}</div>
94
+ <div className="ctrl__prepTitle">EGG-CATCHER</div>
95
+ <div className="ctrl__prepIcon">⤢</div>
96
+ <div className="ctrl__prepCopy">
97
+ Rotate your phone <strong>landscape</strong>.<br />
98
+ Tap anywhere to enter fullscreen.
99
+ </div>
100
+ <button className="ctrl__prepBtn" onClick={arm}>TAP TO CONTINUE</button>
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ if (!landscape) {
107
+ return (
108
+ <div className="ctrl ctrl--rotate">
109
+ <div className="ctrl__rotateIcon">↻</div>
110
+ <div className="ctrl__rotateMsg">ROTATE TO LANDSCAPE</div>
111
+ <div className="ctrl__rotateSub">turn the phone sideways to play</div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // armed + landscape, but game hasn't started → show START screen
117
+ if (!running) {
118
+ return (
119
+ <div className="ctrl ctrl--start">
120
+ <div className="ctrl__startPanel">
121
+ <div className="ctrl__startEyebrow">ROOM {roomId}</div>
122
+ <div className="ctrl__startTitle">
123
+ {paired ? 'READY TO PLAY' : 'CONNECTING…'}
124
+ </div>
125
+ <div className="ctrl__startSub">
126
+ {paired
127
+ ? 'Press START to begin the 60-second round.'
128
+ : 'Linking to the laptop…'}
129
+ </div>
130
+ <button
131
+ className="ctrl__startBtn"
132
+ onClick={() => send(roomId, 'start')}
133
+ disabled={!paired}
134
+ >
135
+ ▸ START
136
+ </button>
137
+ {score > 0 && (
138
+ <div className="ctrl__startLast">Last round: <strong>{score}</strong></div>
139
+ )}
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ const press = (dir) => (e) => { e.preventDefault(); send(roomId, `${dir}:down`); };
146
+ const release = (dir) => (e) => { e.preventDefault(); send(roomId, `${dir}:up`); };
147
+ const ratio = timeLeft == null ? 1 : Math.max(0, Math.min(1, timeLeft / ROUND_MS));
148
+
149
+ return (
150
+ <div className="ctrl ctrl--play">
151
+ <div className="ctrl__topBar">
152
+ <span className={`ctrl__pill ${paired ? 'is-on' : ''}`}>
153
+ ● {paired ? 'LINKED' : 'WAITING'}
154
+ </span>
155
+ <div className="ctrl__scoreBox">
156
+ <span className="ctrl__scoreLabel">SCORE</span>
157
+ <span className="ctrl__scoreNum">{String(score).padStart(4, '0')}</span>
158
+ </div>
159
+ <span className="ctrl__room">ROOM {roomId}</span>
160
+ </div>
161
+
162
+ <div className="ctrl__playArea">
163
+ <div className="ctrl__timeTube" aria-label="Time remaining">
164
+ <div
165
+ className="ctrl__timeFill"
166
+ style={{
167
+ height: `${ratio * 100}%`,
168
+ background: ratio > 0.4 ? '#5db7e8' : ratio > 0.18 ? '#ffd86b' : '#ff9573',
169
+ }}
170
+ />
171
+ </div>
172
+
173
+ <div className="ctrl__pad">
174
+ <button
175
+ type="button"
176
+ className="ctrl__btn ctrl__btn--left"
177
+ onPointerDown={press('left')}
178
+ onPointerUp={release('left')}
179
+ onPointerCancel={release('left')}
180
+ onPointerLeave={release('left')}
181
+ onContextMenu={(e) => e.preventDefault()}
182
+ aria-label="Move left"
183
+ >◀</button>
184
+ <button
185
+ type="button"
186
+ className="ctrl__btn ctrl__btn--right"
187
+ onPointerDown={press('right')}
188
+ onPointerUp={release('right')}
189
+ onPointerCancel={release('right')}
190
+ onPointerLeave={release('right')}
191
+ onContextMenu={(e) => e.preventDefault()}
192
+ aria-label="Move right"
193
+ >▶</button>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
src/Game.jsx ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const GAME_W = 640;
4
+ const GAME_H = 380;
5
+ const CHAR_W = 64;
6
+ const CHAR_H = 40;
7
+ const EGG_R = 14;
8
+ const CHAR_TOP = GAME_H - 60;
9
+ const GROUND_Y = GAME_H - 22;
10
+ const ROUND_MS = 60_000;
11
+
12
+ const COW_W = 30;
13
+ const COW_H = 22;
14
+ const OMELET_TTL = 5500;
15
+
16
+ function postState(roomId, payload) {
17
+ if (!roomId) return;
18
+ fetch('/api/game/state', {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ roomId, ...payload }),
22
+ }).catch(() => {});
23
+ }
24
+
25
+ export default function Game({ username, onGameOver, roomId, lanIp, ipCandidates, onSetLanIp }) {
26
+ const canvasRef = React.useRef(null);
27
+ const stageRef = React.useRef(null);
28
+
29
+ const [paired, setPaired] = React.useState(false);
30
+ const [running, setRunning] = React.useState(false);
31
+ const [score, setScore] = React.useState(0);
32
+ const [timeLeft, setTimeLeft] = React.useState(ROUND_MS);
33
+ const [isFs, setIsFs] = React.useState(false);
34
+
35
+ const stateRef = React.useRef({
36
+ keys: { left: false, right: false },
37
+ char: { x: GAME_W / 2 - CHAR_W / 2 },
38
+ eggs: [],
39
+ omelets: [],
40
+ cows: [],
41
+ nextCowMs: 2200,
42
+ clouds: [
43
+ { x: 70, y: 44, w: 110, h: 38 },
44
+ { x: 260, y: 30, w: 130, h: 44 },
45
+ { x: 460, y: 50, w: 110, h: 38 },
46
+ ],
47
+ lastSpawn: 0,
48
+ spawnGap: 1300,
49
+ fallSpeed: 1.4,
50
+ score: 0,
51
+ running: false,
52
+ elapsed: 0,
53
+ flash: 0,
54
+ timeLeft: ROUND_MS,
55
+ });
56
+
57
+ // SSE — receive controller events (left/right/start)
58
+ React.useEffect(() => {
59
+ if (!roomId) return;
60
+ const es = new EventSource(`/api/game/events/${roomId}`);
61
+ es.addEventListener('ready', (e) => {
62
+ try { setPaired(!!JSON.parse(e.data).paired); } catch {}
63
+ });
64
+ es.addEventListener('control', (e) => {
65
+ try {
66
+ const { action } = JSON.parse(e.data);
67
+ if (!paired) setPaired(true);
68
+ if (action === 'left:down') stateRef.current.keys.left = true;
69
+ else if (action === 'left:up') stateRef.current.keys.left = false;
70
+ else if (action === 'right:down') stateRef.current.keys.right = true;
71
+ else if (action === 'right:up') stateRef.current.keys.right = false;
72
+ else if (action === 'start') startRef.current?.();
73
+ } catch {}
74
+ });
75
+ es.onerror = () => {};
76
+ return () => es.close();
77
+ }, [roomId, paired]);
78
+
79
+ // Keyboard fallback
80
+ React.useEffect(() => {
81
+ const dn = (e) => {
82
+ if (e.key === 'ArrowLeft' || e.key === 'a') stateRef.current.keys.left = true;
83
+ if (e.key === 'ArrowRight' || e.key === 'd') stateRef.current.keys.right = true;
84
+ };
85
+ const up = (e) => {
86
+ if (e.key === 'ArrowLeft' || e.key === 'a') stateRef.current.keys.left = false;
87
+ if (e.key === 'ArrowRight' || e.key === 'd') stateRef.current.keys.right = false;
88
+ };
89
+ window.addEventListener('keydown', dn);
90
+ window.addEventListener('keyup', up);
91
+ return () => {
92
+ window.removeEventListener('keydown', dn);
93
+ window.removeEventListener('keyup', up);
94
+ };
95
+ }, []);
96
+
97
+ React.useEffect(() => {
98
+ postState(roomId, { score, running, timeLeft });
99
+ }, [roomId, score, running, timeLeft]);
100
+
101
+ React.useEffect(() => {
102
+ const onFsChange = () => setIsFs(!!document.fullscreenElement);
103
+ document.addEventListener('fullscreenchange', onFsChange);
104
+ setIsFs(!!document.fullscreenElement);
105
+ return () => document.removeEventListener('fullscreenchange', onFsChange);
106
+ }, []);
107
+
108
+ const endGameRef = React.useRef(null);
109
+ endGameRef.current = async () => {
110
+ const finalScore = stateRef.current.score;
111
+ stateRef.current.running = false;
112
+ setRunning(false);
113
+ try {
114
+ const res = await fetch('/api/score', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ username, score: finalScore }),
118
+ }).then((r) => r.json());
119
+ onGameOver({ score: finalScore, ...res });
120
+ } catch {
121
+ onGameOver({ score: finalScore, ok: false });
122
+ }
123
+ };
124
+
125
+ const startRef = React.useRef(null);
126
+ const start = React.useCallback(() => {
127
+ const s = stateRef.current;
128
+ if (s.running) return;
129
+ s.score = 0;
130
+ s.eggs = [];
131
+ s.omelets = [];
132
+ s.cows = [];
133
+ s.nextCowMs = 3000;
134
+ s.elapsed = 0;
135
+ s.lastSpawn = 0;
136
+ s.char.x = GAME_W / 2 - CHAR_W / 2;
137
+ s.timeLeft = ROUND_MS;
138
+ s.running = true;
139
+ setScore(0);
140
+ setTimeLeft(ROUND_MS);
141
+ setRunning(true);
142
+ }, []);
143
+ startRef.current = start;
144
+
145
+ // Main loop
146
+ React.useEffect(() => {
147
+ const cvs = canvasRef.current;
148
+ if (!cvs) return;
149
+ const ctx = cvs.getContext('2d');
150
+ let raf = 0;
151
+ let last = performance.now();
152
+
153
+ const tick = (now) => {
154
+ const dt = Math.min(48, now - last);
155
+ last = now;
156
+ const s = stateRef.current;
157
+
158
+ if (s.running) {
159
+ const speed = 4.4;
160
+ if (s.keys.left) s.char.x -= speed;
161
+ if (s.keys.right) s.char.x += speed;
162
+ s.char.x = Math.max(8, Math.min(GAME_W - CHAR_W - 8, s.char.x));
163
+
164
+ s.elapsed += dt;
165
+ s.timeLeft = Math.max(0, s.timeLeft - dt);
166
+ s.lastSpawn += dt;
167
+ s.spawnGap = Math.max(550, 1300 - s.elapsed * 0.04);
168
+ s.fallSpeed = Math.min(3.6, 1.4 + s.elapsed * 0.00012);
169
+
170
+ if (s.lastSpawn >= s.spawnGap) {
171
+ s.lastSpawn = 0;
172
+ const cloud = s.clouds[Math.floor(Math.random() * s.clouds.length)];
173
+ s.eggs.push({
174
+ x: cloud.x + Math.random() * (cloud.w - 10) + 5,
175
+ y: cloud.y + cloud.h - 4,
176
+ vy: s.fallSpeed,
177
+ });
178
+ }
179
+
180
+ for (const egg of s.eggs) egg.y += egg.vy * (dt / 16.67);
181
+
182
+ const remaining = [];
183
+ let scored = 0;
184
+ const left = s.char.x;
185
+ const right = s.char.x + CHAR_W;
186
+ const top = CHAR_TOP - 6;
187
+ const bottom = CHAR_TOP + CHAR_H;
188
+ for (const egg of s.eggs) {
189
+ const cx = Math.max(left, Math.min(egg.x, right));
190
+ const cy = Math.max(top, Math.min(egg.y, bottom));
191
+ const dx = egg.x - cx;
192
+ const dy = egg.y - cy;
193
+ if (dx * dx + dy * dy <= EGG_R * EGG_R) {
194
+ scored += 1;
195
+ continue;
196
+ }
197
+ if (egg.y >= GROUND_Y - 2) {
198
+ s.omelets.push({ x: egg.x, y: GROUND_Y + 2, ttl: OMELET_TTL });
199
+ continue;
200
+ }
201
+ remaining.push(egg);
202
+ }
203
+ s.eggs = remaining;
204
+
205
+ // omelets age out
206
+ const omRemain = [];
207
+ for (const o of s.omelets) {
208
+ o.ttl -= dt;
209
+ if (o.ttl > 0) omRemain.push(o);
210
+ }
211
+ s.omelets = omRemain;
212
+
213
+ // spawn cows occasionally when there are omelets to eat (or just for ambience)
214
+ s.nextCowMs -= dt;
215
+ if (s.nextCowMs <= 0 && s.cows.length < 2) {
216
+ const fromLeft = Math.random() < 0.5;
217
+ s.cows.push({
218
+ x: fromLeft ? -COW_W : GAME_W + COW_W,
219
+ dir: fromLeft ? 1 : -1,
220
+ speed: 0.55 + Math.random() * 0.35,
221
+ wobble: 0,
222
+ });
223
+ s.nextCowMs = 3500 + Math.random() * 4500;
224
+ }
225
+
226
+ // move cows + eat omelets they walk over
227
+ const cowRemain = [];
228
+ for (const cow of s.cows) {
229
+ cow.x += cow.dir * cow.speed * (dt / 16.67);
230
+ cow.wobble += dt;
231
+ // eat
232
+ const cowCenter = cow.x + COW_W / 2;
233
+ s.omelets = s.omelets.filter((o) => !(Math.abs(o.x - cowCenter) < 16));
234
+ if (cow.x > -COW_W - 4 && cow.x < GAME_W + COW_W + 4) cowRemain.push(cow);
235
+ }
236
+ s.cows = cowRemain;
237
+
238
+ if (scored > 0) {
239
+ s.score += scored * 10;
240
+ s.flash = 6;
241
+ setScore(s.score);
242
+ }
243
+ if (s.flash > 0) s.flash -= 1;
244
+
245
+ setTimeLeft(s.timeLeft);
246
+ if (s.timeLeft <= 0) endGameRef.current?.();
247
+ }
248
+
249
+ draw(ctx, s);
250
+ raf = requestAnimationFrame(tick);
251
+ };
252
+
253
+ raf = requestAnimationFrame(tick);
254
+ return () => cancelAnimationFrame(raf);
255
+ }, []);
256
+
257
+ const toggleFs = async () => {
258
+ try {
259
+ if (document.fullscreenElement) await document.exitFullscreen();
260
+ else if (stageRef.current?.requestFullscreen) await stageRef.current.requestFullscreen();
261
+ } catch {}
262
+ };
263
+
264
+ const controllerUrl = roomId
265
+ ? (import.meta.env.PROD
266
+ ? `${window.location.origin}/controller?room=${roomId}`
267
+ : lanIp ? `http://${lanIp}:5173/controller?room=${roomId}` : null)
268
+ : null;
269
+
270
+ const qrSrc = controllerUrl
271
+ ? `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&color=1A1530&bgcolor=FFF7F1&data=${encodeURIComponent(controllerUrl)}`
272
+ : null;
273
+
274
+ return (
275
+ <section className="game game--solo">
276
+ <div className={`game__stage ${isFs ? 'is-fs' : ''}`} ref={stageRef}>
277
+ <div className="game__hud">
278
+ <div className="game__hudCell">
279
+ <span className="game__hudLabel">PLAYER</span>
280
+ <span className="game__hudNum game__hudNum--sm">{username}</span>
281
+ </div>
282
+ <div className="game__hudCell">
283
+ <span className="game__hudLabel">SCORE</span>
284
+ <span className="game__hudNum">{String(score).padStart(4, '0')}</span>
285
+ </div>
286
+ <div className="game__hudCell game__hudCell--actions">
287
+ <button className="game__iconBtn game__iconBtn--alt" onClick={toggleFs} title="Toggle fullscreen">
288
+ {isFs ? '⤓ EXIT FS' : '⤢ FULLSCREEN'}
289
+ </button>
290
+ </div>
291
+ </div>
292
+
293
+ <div className="game__canvasWrap">
294
+ <canvas ref={canvasRef} width={GAME_W} height={GAME_H} className="game__canvas" />
295
+
296
+ {!running && (
297
+ <div className="game__popup">
298
+ <div className="game__popupInner">
299
+ <div className="game__popupHead">
300
+ <span className="game__qrDot" />
301
+ <span className="game__popupTitle">PHONE CONTROLLER</span>
302
+ </div>
303
+
304
+ <div className="game__popupBody">
305
+ <div className="game__popupQrBox">
306
+ {qrSrc ? (
307
+ <img src={qrSrc} alt="QR code" />
308
+ ) : (
309
+ <div className="game__qrSkeleton">generating…</div>
310
+ )}
311
+ </div>
312
+
313
+ <div className="game__popupCopy">
314
+ {!paired ? (
315
+ <>
316
+ <div className="game__popupLine">▸ Scan the QR with your phone.</div>
317
+ <div className="game__popupLine">▸ Phone &amp; laptop on same wifi.</div>
318
+ <div className="game__popupLine">▸ Controller will have a START button.</div>
319
+ </>
320
+ ) : (
321
+ <>
322
+ <div className="game__popupLine game__popupLine--ok">✓ Controller linked.</div>
323
+ <div className="game__popupLine">Press <strong>START</strong> on your phone to begin.</div>
324
+ </>
325
+ )}
326
+ <div className="game__popupRoom">
327
+ Room <strong>{roomId || '——'}</strong>
328
+ </div>
329
+ {controllerUrl && (
330
+ <div className="game__popupUrl"><code>{controllerUrl}</code></div>
331
+ )}
332
+ {ipCandidates && ipCandidates.length > 1 && (
333
+ <label className="game__ipPick">
334
+ <span>QR not opening? Try another adapter:</span>
335
+ <select value={lanIp || ''} onChange={(e) => onSetLanIp?.(e.target.value)}>
336
+ {ipCandidates.map((c) => (
337
+ <option key={c.address} value={c.address}>{c.address} — {c.name}</option>
338
+ ))}
339
+ </select>
340
+ </label>
341
+ )}
342
+ </div>
343
+ </div>
344
+
345
+ <div className="game__popupFoot">
346
+ <span className="game__popupHint">— or use keyboard ↓</span>
347
+ <button className="btn btn--yellow" onClick={start}>
348
+ START WITH KEYBOARD ▸
349
+ </button>
350
+ </div>
351
+ </div>
352
+ </div>
353
+ )}
354
+ </div>
355
+
356
+ <div className="game__foot">
357
+ <span className={`game__pill ${paired ? 'is-on' : ''}`}>
358
+ ● {paired ? 'CONTROLLER LINKED' : 'WAITING FOR CONTROLLER'}
359
+ </span>
360
+ <span className="game__pill game__pill--muted">ROOM {roomId || '——'}</span>
361
+ </div>
362
+ </div>
363
+ </section>
364
+ );
365
+ }
366
+
367
+ /* ---------- drawing ---------- */
368
+
369
+ function draw(ctx, s) {
370
+ const grad = ctx.createLinearGradient(0, 0, 0, GAME_H);
371
+ grad.addColorStop(0, '#cfe9ff');
372
+ grad.addColorStop(1, '#fde5b1');
373
+ ctx.fillStyle = grad;
374
+ ctx.fillRect(0, 0, GAME_W, GAME_H);
375
+
376
+ ctx.fillStyle = '#ffd86b';
377
+ pixelCircle(ctx, 580, 60, 22);
378
+
379
+ // grass
380
+ ctx.fillStyle = '#b8dca0';
381
+ ctx.fillRect(0, GROUND_Y, GAME_W, 22);
382
+ ctx.fillStyle = '#88b86c';
383
+ for (let x = 0; x < GAME_W; x += 18) ctx.fillRect(x, GROUND_Y, 8, 4);
384
+
385
+ for (const c of s.clouds) drawCloud(ctx, c);
386
+
387
+ for (const o of s.omelets) drawOmelet(ctx, o);
388
+ for (const cow of s.cows) drawCow(ctx, cow);
389
+
390
+ for (const e of s.eggs) drawEgg(ctx, e.x, e.y);
391
+ drawBasket(ctx, s.char.x, CHAR_TOP);
392
+
393
+ if (s.flash > 0) {
394
+ ctx.fillStyle = `rgba(255, 216, 107, ${s.flash * 0.06})`;
395
+ ctx.fillRect(0, 0, GAME_W, GAME_H);
396
+ }
397
+
398
+ drawTimeBar(ctx, s.timeLeft / ROUND_MS, s.running);
399
+
400
+ if (!s.running) {
401
+ ctx.fillStyle = 'rgba(26, 21, 48, 0.45)';
402
+ ctx.fillRect(0, 0, GAME_W, GAME_H);
403
+ }
404
+ }
405
+
406
+ function pixelCircle(ctx, cx, cy, r) {
407
+ for (let y = -r; y <= r; y += 2) {
408
+ const w = Math.floor(Math.sqrt(r * r - y * y));
409
+ ctx.fillRect(cx - w, cy + y, w * 2, 2);
410
+ }
411
+ }
412
+
413
+ function drawCloud(ctx, c) {
414
+ const { x, y, w, h } = c;
415
+ ctx.fillStyle = '#ffffff';
416
+ ctx.fillRect(x + 6, y, w - 12, h);
417
+ ctx.fillRect(x, y + 8, w, h - 16);
418
+ ctx.fillStyle = 'rgba(105, 116, 158, 0.35)';
419
+ ctx.fillRect(x + 6, y + h, w - 12, 4);
420
+ ctx.fillStyle = '#1a1530';
421
+ ctx.fillRect(x + Math.floor(w * 0.35), y + Math.floor(h * 0.45), 4, 4);
422
+ ctx.fillRect(x + Math.floor(w * 0.55), y + Math.floor(h * 0.45), 4, 4);
423
+ }
424
+
425
+ function drawEgg(ctx, cx, cy) {
426
+ ctx.fillStyle = 'rgba(26, 21, 48, 0.18)';
427
+ ctx.fillRect(cx - 8, cy + 12, 16, 3);
428
+ ctx.fillStyle = '#fff7f1';
429
+ ctx.fillRect(cx - 6, cy - 10, 12, 4);
430
+ ctx.fillRect(cx - 8, cy - 6, 16, 14);
431
+ ctx.fillRect(cx - 6, cy + 8, 12, 2);
432
+ ctx.fillStyle = '#ffd86b';
433
+ ctx.fillRect(cx + 1, cy - 2, 3, 3);
434
+ ctx.fillRect(cx - 4, cy + 2, 2, 2);
435
+ ctx.fillStyle = '#1a1530';
436
+ ctx.fillRect(cx - 6, cy - 11, 12, 1);
437
+ ctx.fillRect(cx - 6, cy + 9, 12, 1);
438
+ ctx.fillRect(cx - 9, cy - 6, 1, 14);
439
+ ctx.fillRect(cx + 8, cy - 6, 1, 14);
440
+ }
441
+
442
+ function drawOmelet(ctx, o) {
443
+ const { x, y, ttl } = o;
444
+ const fade = ttl < 1200 ? Math.max(0.35, ttl / 1200) : 1;
445
+ ctx.globalAlpha = fade;
446
+ // egg-white splash
447
+ ctx.fillStyle = '#fff7f1';
448
+ ctx.fillRect(x - 10, y - 2, 20, 4);
449
+ ctx.fillRect(x - 8, y - 4, 16, 2);
450
+ ctx.fillRect(x - 12, y, 24, 2);
451
+ // yolk
452
+ ctx.fillStyle = '#ffd86b';
453
+ ctx.fillRect(x - 4, y - 2, 8, 4);
454
+ ctx.fillRect(x - 3, y - 3, 6, 1);
455
+ ctx.fillStyle = '#f0bf3a';
456
+ ctx.fillRect(x - 2, y - 1, 4, 2);
457
+ // shell shards
458
+ ctx.fillStyle = '#fff7f1';
459
+ ctx.fillRect(x - 14, y + 1, 2, 2);
460
+ ctx.fillRect(x + 12, y - 1, 2, 2);
461
+ // outline
462
+ ctx.fillStyle = '#1a1530';
463
+ ctx.fillRect(x - 12, y - 1, 1, 3);
464
+ ctx.fillRect(x + 11, y - 1, 1, 3);
465
+ ctx.globalAlpha = 1;
466
+ }
467
+
468
+ function drawCow(ctx, cow) {
469
+ const { x, dir, wobble } = cow;
470
+ const bob = Math.floor(wobble / 120) % 2; // walk cycle
471
+ const y = GROUND_Y - COW_H + 6 + bob;
472
+ const flip = dir < 0;
473
+ ctx.save();
474
+ if (flip) {
475
+ ctx.translate(x + COW_W, 0);
476
+ ctx.scale(-1, 1);
477
+ } else {
478
+ ctx.translate(x, 0);
479
+ }
480
+
481
+ // body
482
+ ctx.fillStyle = '#fff7f1';
483
+ ctx.fillRect(4, y + 4, 22, 10);
484
+ ctx.fillRect(2, y + 6, 26, 6);
485
+ // spots
486
+ ctx.fillStyle = '#1a1530';
487
+ ctx.fillRect(7, y + 5, 4, 3);
488
+ ctx.fillRect(15, y + 8, 5, 3);
489
+ ctx.fillRect(20, y + 5, 3, 2);
490
+ // head
491
+ ctx.fillStyle = '#fff7f1';
492
+ ctx.fillRect(22, y + 2, 8, 8);
493
+ ctx.fillRect(24, y, 4, 2);
494
+ // muzzle
495
+ ctx.fillStyle = '#ffb79d';
496
+ ctx.fillRect(28, y + 6, 3, 3);
497
+ // eye
498
+ ctx.fillStyle = '#1a1530';
499
+ ctx.fillRect(26, y + 4, 1, 1);
500
+ // horns
501
+ ctx.fillStyle = '#1a1530';
502
+ ctx.fillRect(23, y - 1, 1, 2);
503
+ ctx.fillRect(27, y - 1, 1, 2);
504
+ // udder
505
+ ctx.fillStyle = '#ffb79d';
506
+ ctx.fillRect(10, y + 13, 4, 3);
507
+ // legs (alternate)
508
+ ctx.fillStyle = '#1a1530';
509
+ const legY = y + 14;
510
+ if (bob === 0) {
511
+ ctx.fillRect(5, legY, 2, 6);
512
+ ctx.fillRect(11, legY, 2, 4);
513
+ ctx.fillRect(17, legY, 2, 6);
514
+ ctx.fillRect(23, legY, 2, 4);
515
+ } else {
516
+ ctx.fillRect(5, legY, 2, 4);
517
+ ctx.fillRect(11, legY, 2, 6);
518
+ ctx.fillRect(17, legY, 2, 4);
519
+ ctx.fillRect(23, legY, 2, 6);
520
+ }
521
+ // tail
522
+ ctx.fillRect(2, y + 5, 1, 4);
523
+
524
+ // outline
525
+ ctx.fillStyle = '#1a1530';
526
+ ctx.fillRect(2, y + 6, 26, 1);
527
+ ctx.fillRect(2, y + 11, 26, 1);
528
+ ctx.fillRect(2, y + 6, 1, 6);
529
+ ctx.fillRect(27, y + 6, 1, 6);
530
+ ctx.fillRect(22, y + 2, 1, 8);
531
+ ctx.fillRect(30, y + 2, 1, 8);
532
+ ctx.fillRect(22, y + 1, 9, 1);
533
+ ctx.fillRect(22, y + 9, 9, 1);
534
+
535
+ ctx.restore();
536
+ }
537
+
538
+ function drawBasket(ctx, x, y) {
539
+ ctx.fillStyle = '#1a1530';
540
+ ctx.fillRect(x + 12, y - 14, CHAR_W - 24, 4);
541
+ ctx.fillRect(x + 8, y - 10, 4, 8);
542
+ ctx.fillRect(x + CHAR_W - 12, y - 10, 4, 8);
543
+
544
+ ctx.fillStyle = '#ff9573';
545
+ ctx.fillRect(x, y, CHAR_W, CHAR_H);
546
+ ctx.fillStyle = '#ffb79d';
547
+ ctx.fillRect(x + 4, y + 4, CHAR_W - 8, 6);
548
+ ctx.fillStyle = '#ff9573';
549
+ for (let i = 0; i < CHAR_W; i += 8) {
550
+ ctx.fillRect(x + i, y + 12, 4, 4);
551
+ ctx.fillRect(x + i + 4, y + 20, 4, 4);
552
+ ctx.fillRect(x + i, y + 28, 4, 4);
553
+ }
554
+ ctx.fillStyle = '#1a1530';
555
+ ctx.fillRect(x, y, CHAR_W, 2);
556
+ ctx.fillRect(x, y + CHAR_H - 2, CHAR_W, 2);
557
+ ctx.fillRect(x, y, 2, CHAR_H);
558
+ ctx.fillRect(x + CHAR_W - 2, y, 2, CHAR_H);
559
+ }
560
+
561
+ function drawTimeBar(ctx, ratio, running) {
562
+ const ax = 8;
563
+ const ay = 22;
564
+ const aw = 16;
565
+ const ah = GAME_H - 48;
566
+ const inner = 3;
567
+ const innerH = ah - inner * 2;
568
+ const fillH = Math.max(0, Math.min(innerH, Math.floor(innerH * ratio)));
569
+
570
+ // tube outline
571
+ ctx.fillStyle = '#1a1530';
572
+ ctx.fillRect(ax, ay, aw, ah);
573
+ ctx.fillStyle = '#fff7f1';
574
+ ctx.fillRect(ax + inner, ay + inner, aw - inner * 2, ah - inner * 2);
575
+
576
+ // water (bottom-anchored, drains downward as time decreases)
577
+ const waterColor = ratio > 0.4 ? '#5db7e8' : ratio > 0.18 ? '#ffd86b' : '#ff9573';
578
+ ctx.fillStyle = waterColor;
579
+ ctx.fillRect(ax + inner, ay + inner + (innerH - fillH), aw - inner * 2, fillH);
580
+
581
+ // water surface — simple pixel ripple
582
+ if (fillH > 2 && running) {
583
+ const surfaceY = ay + inner + (innerH - fillH);
584
+ ctx.fillStyle = '#1a1530';
585
+ ctx.fillRect(ax + inner, surfaceY, aw - inner * 2, 1);
586
+ ctx.fillStyle = '#ffffff';
587
+ const t = Math.floor(performance.now() / 200) % 2;
588
+ ctx.fillRect(ax + inner + (t ? 0 : 4), surfaceY + 1, 4, 1);
589
+ }
590
+
591
+ // tick marks every quarter
592
+ ctx.fillStyle = '#1a1530';
593
+ for (let i = 1; i < 4; i++) {
594
+ const ty = ay + inner + Math.floor((innerH * i) / 4);
595
+ ctx.fillRect(ax - 2, ty, 3, 1);
596
+ }
597
+ }
src/Leaderboard.jsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function Leaderboard({ username, lastResult, onPlayAgain, onChangeUser }) {
4
+ const [data, setData] = React.useState(null);
5
+ const [loading, setLoading] = React.useState(true);
6
+ const [err, setErr] = React.useState('');
7
+
8
+ React.useEffect(() => {
9
+ let alive = true;
10
+ (async () => {
11
+ try {
12
+ const r = await fetch(`/api/leaderboard?limit=25&username=${encodeURIComponent(username)}`)
13
+ .then((r) => r.json());
14
+ if (!alive) return;
15
+ if (!r.ok) throw new Error(r.error || 'failed');
16
+ setData(r);
17
+ } catch (e) {
18
+ if (alive) setErr(e.message || 'Could not load leaderboard.');
19
+ } finally {
20
+ if (alive) setLoading(false);
21
+ }
22
+ })();
23
+ return () => { alive = false; };
24
+ }, [username, lastResult]);
25
+
26
+ const myRow = data?.me;
27
+ const inTop = !!myRow && data?.top?.some((t) => t.username.toLowerCase() === username.toLowerCase());
28
+
29
+ return (
30
+ <div className="lb">
31
+ <div className="lb__panel">
32
+ <div className="lb__head">
33
+ <div>
34
+ <div className="eyebrow">▸ GLOBAL LEADERBOARD</div>
35
+ <h1 className="lb__title">
36
+ {lastResult ? (
37
+ <>NICE ROUND, <span className="accent-y">{username}</span>!</>
38
+ ) : (
39
+ <>HALL OF <span className="accent-y">EGGS</span></>
40
+ )}
41
+ </h1>
42
+ </div>
43
+ <div className="lb__actions">
44
+ <button className="tbtn" onClick={onPlayAgain}>▸ PLAY AGAIN</button>
45
+ <button className="tbtn tbtn--ghost" onClick={onChangeUser}>↺ NEW USER</button>
46
+ </div>
47
+ </div>
48
+
49
+ {lastResult && (
50
+ <div className="lb__resultCard">
51
+ <div className="lb__resultCell">
52
+ <span className="lb__resultLabel">THIS ROUND</span>
53
+ <span className="lb__resultNum">{lastResult.score}</span>
54
+ </div>
55
+ <div className="lb__resultCell">
56
+ <span className="lb__resultLabel">YOUR BEST</span>
57
+ <span className="lb__resultNum">{lastResult.best ?? '—'}</span>
58
+ </div>
59
+ <div className="lb__resultCell">
60
+ <span className="lb__resultLabel">RANK</span>
61
+ <span className="lb__resultNum">
62
+ {lastResult.rank ? `#${lastResult.rank}` : '—'}
63
+ {lastResult.total ? <span className="lb__resultSub"> of {lastResult.total}</span> : null}
64
+ </span>
65
+ </div>
66
+ </div>
67
+ )}
68
+
69
+ {loading && <div className="lb__loading">loading scores…</div>}
70
+ {err && <div className="lb__err">✕ {err}</div>}
71
+
72
+ {data && (
73
+ <>
74
+ <div className="lb__tableHead">
75
+ <span className="lb__col lb__col--rank">#</span>
76
+ <span className="lb__col lb__col--name">PLAYER</span>
77
+ <span className="lb__col lb__col--games">GAMES</span>
78
+ <span className="lb__col lb__col--score">BEST</span>
79
+ </div>
80
+ <ol className="lb__rows">
81
+ {data.top.length === 0 && (
82
+ <li className="lb__empty">No scores yet — yours will be the first!</li>
83
+ )}
84
+ {data.top.map((row, i) => {
85
+ const isMe = row.username.toLowerCase() === username.toLowerCase();
86
+ return (
87
+ <li key={row.username} className={`lb__row ${isMe ? 'is-me' : ''}`}>
88
+ <span className={`lb__col lb__col--rank lb__rank lb__rank--${i + 1}`}>
89
+ {i + 1}
90
+ </span>
91
+ <span className="lb__col lb__col--name">
92
+ {row.username}{isMe ? <span className="lb__mePill">YOU</span> : null}
93
+ </span>
94
+ <span className="lb__col lb__col--games">{row.games}</span>
95
+ <span className="lb__col lb__col--score">{row.best}</span>
96
+ </li>
97
+ );
98
+ })}
99
+ </ol>
100
+
101
+ {myRow && !inTop && (
102
+ <>
103
+ <div className="lb__dots">· · ·</div>
104
+ <ol className="lb__rows lb__rows--solo">
105
+ <li className="lb__row is-me">
106
+ <span className="lb__col lb__col--rank">{myRow.rank}</span>
107
+ <span className="lb__col lb__col--name">
108
+ {myRow.username}<span className="lb__mePill">YOU</span>
109
+ </span>
110
+ <span className="lb__col lb__col--games">{myRow.games}</span>
111
+ <span className="lb__col lb__col--score">{myRow.best}</span>
112
+ </li>
113
+ </ol>
114
+ </>
115
+ )}
116
+ </>
117
+ )}
118
+ </div>
119
+ </div>
120
+ );
121
+ }
src/MobileGame.jsx ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const GAME_W = 640;
4
+ const GAME_H = 380;
5
+ const CHAR_W = 64;
6
+ const CHAR_H = 40;
7
+ const EGG_R = 14;
8
+ const CHAR_TOP = GAME_H - 60;
9
+ const GROUND_Y = GAME_H - 22;
10
+ const ROUND_MS = 60_000;
11
+ const COW_W = 30;
12
+ const COW_H = 22;
13
+ const OMELET_TTL = 5500;
14
+
15
+ export default function MobileGame({ username, onGameOver }) {
16
+ const canvasRef = React.useRef(null);
17
+ const [running, setRunning] = React.useState(false);
18
+ const [score, setScore] = React.useState(0);
19
+
20
+ const stateRef = React.useRef({
21
+ keys: { left: false, right: false },
22
+ char: { x: GAME_W / 2 - CHAR_W / 2 },
23
+ eggs: [],
24
+ omelets: [],
25
+ cows: [],
26
+ nextCowMs: 2500,
27
+ clouds: [
28
+ { x: 70, y: 44, w: 110, h: 38 },
29
+ { x: 260, y: 30, w: 130, h: 44 },
30
+ { x: 460, y: 50, w: 110, h: 38 },
31
+ ],
32
+ lastSpawn: 0,
33
+ spawnGap: 1300,
34
+ fallSpeed: 1.4,
35
+ score: 0,
36
+ running: false,
37
+ elapsed: 0,
38
+ flash: 0,
39
+ timeLeft: ROUND_MS,
40
+ });
41
+
42
+ const endGameRef = React.useRef(null);
43
+ endGameRef.current = async () => {
44
+ const finalScore = stateRef.current.score;
45
+ stateRef.current.running = false;
46
+ setRunning(false);
47
+ try {
48
+ const res = await fetch('/api/score', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ username, score: finalScore }),
52
+ }).then((r) => r.json());
53
+ onGameOver({ score: finalScore, ...res });
54
+ } catch {
55
+ onGameOver({ score: finalScore, ok: false });
56
+ }
57
+ };
58
+
59
+ React.useEffect(() => {
60
+ const cvs = canvasRef.current;
61
+ if (!cvs) return;
62
+ const ctx = cvs.getContext('2d');
63
+ let raf = 0;
64
+ let last = performance.now();
65
+
66
+ const tick = (now) => {
67
+ const dt = Math.min(48, now - last);
68
+ last = now;
69
+ const s = stateRef.current;
70
+
71
+ if (s.running) {
72
+ const speed = 4.6;
73
+ if (s.keys.left) s.char.x -= speed;
74
+ if (s.keys.right) s.char.x += speed;
75
+ s.char.x = Math.max(8, Math.min(GAME_W - CHAR_W - 8, s.char.x));
76
+
77
+ s.elapsed += dt;
78
+ s.timeLeft = Math.max(0, s.timeLeft - dt);
79
+ s.lastSpawn += dt;
80
+ s.spawnGap = Math.max(550, 1300 - s.elapsed * 0.04);
81
+ s.fallSpeed = Math.min(3.6, 1.4 + s.elapsed * 0.00012);
82
+ if (s.lastSpawn >= s.spawnGap) {
83
+ s.lastSpawn = 0;
84
+ const cloud = s.clouds[Math.floor(Math.random() * s.clouds.length)];
85
+ s.eggs.push({
86
+ x: cloud.x + Math.random() * (cloud.w - 10) + 5,
87
+ y: cloud.y + cloud.h - 4,
88
+ vy: s.fallSpeed,
89
+ });
90
+ }
91
+
92
+ for (const egg of s.eggs) egg.y += egg.vy * (dt / 16.67);
93
+
94
+ const remaining = [];
95
+ let scored = 0;
96
+ const left = s.char.x;
97
+ const right = s.char.x + CHAR_W;
98
+ const top = CHAR_TOP - 6;
99
+ const bottom = CHAR_TOP + CHAR_H;
100
+ for (const egg of s.eggs) {
101
+ const cx = Math.max(left, Math.min(egg.x, right));
102
+ const cy = Math.max(top, Math.min(egg.y, bottom));
103
+ const dx = egg.x - cx;
104
+ const dy = egg.y - cy;
105
+ if (dx * dx + dy * dy <= EGG_R * EGG_R) {
106
+ scored += 1;
107
+ continue;
108
+ }
109
+ if (egg.y >= GROUND_Y - 2) {
110
+ s.omelets.push({ x: egg.x, y: GROUND_Y + 2, ttl: OMELET_TTL });
111
+ continue;
112
+ }
113
+ remaining.push(egg);
114
+ }
115
+ s.eggs = remaining;
116
+
117
+ s.omelets = s.omelets.filter((o) => (o.ttl -= dt) > 0);
118
+
119
+ s.nextCowMs -= dt;
120
+ if (s.nextCowMs <= 0 && s.cows.length < 2) {
121
+ const fromLeft = Math.random() < 0.5;
122
+ s.cows.push({
123
+ x: fromLeft ? -COW_W : GAME_W + COW_W,
124
+ dir: fromLeft ? 1 : -1,
125
+ speed: 0.55 + Math.random() * 0.35,
126
+ wobble: 0,
127
+ });
128
+ s.nextCowMs = 3500 + Math.random() * 4500;
129
+ }
130
+
131
+ const cowRemain = [];
132
+ for (const cow of s.cows) {
133
+ cow.x += cow.dir * cow.speed * (dt / 16.67);
134
+ cow.wobble += dt;
135
+ const cowCenter = cow.x + COW_W / 2;
136
+ s.omelets = s.omelets.filter((o) => !(Math.abs(o.x - cowCenter) < 16));
137
+ if (cow.x > -COW_W - 4 && cow.x < GAME_W + COW_W + 4) cowRemain.push(cow);
138
+ }
139
+ s.cows = cowRemain;
140
+
141
+ if (scored > 0) {
142
+ s.score += scored * 10;
143
+ s.flash = 6;
144
+ setScore(s.score);
145
+ }
146
+ if (s.flash > 0) s.flash -= 1;
147
+
148
+ if (s.timeLeft <= 0) endGameRef.current?.();
149
+ }
150
+
151
+ draw(ctx, s);
152
+ raf = requestAnimationFrame(tick);
153
+ };
154
+
155
+ raf = requestAnimationFrame(tick);
156
+ return () => cancelAnimationFrame(raf);
157
+ }, []);
158
+
159
+ const start = () => {
160
+ const s = stateRef.current;
161
+ s.score = 0;
162
+ s.eggs = [];
163
+ s.omelets = [];
164
+ s.cows = [];
165
+ s.nextCowMs = 3000;
166
+ s.elapsed = 0;
167
+ s.lastSpawn = 0;
168
+ s.char.x = GAME_W / 2 - CHAR_W / 2;
169
+ s.timeLeft = ROUND_MS;
170
+ s.running = true;
171
+ setScore(0);
172
+ setRunning(true);
173
+ };
174
+
175
+ const press = (dir) => (e) => { e.preventDefault(); stateRef.current.keys[dir] = true; };
176
+ const release = (dir) => (e) => { e.preventDefault(); stateRef.current.keys[dir] = false; };
177
+
178
+ return (
179
+ <section className="mgame">
180
+ <div className="mgame__hud">
181
+ <div className="mgame__hudCell">
182
+ <span className="mgame__hudLabel">PLAYER</span>
183
+ <span className="mgame__hudNum mgame__hudNum--sm">{username}</span>
184
+ </div>
185
+ <div className="mgame__hudCell">
186
+ <span className="mgame__hudLabel">SCORE</span>
187
+ <span className="mgame__hudNum">{String(score).padStart(4, '0')}</span>
188
+ </div>
189
+ </div>
190
+
191
+ <div className="mgame__canvasWrap">
192
+ <canvas ref={canvasRef} width={GAME_W} height={GAME_H} className="mgame__canvas" />
193
+ {!running && (
194
+ <div className="mgame__overlay">
195
+ <div className="mgame__overlayInner">
196
+ <div className="mgame__overlayTitle">EGG-CATCHER</div>
197
+ <div className="mgame__overlayCopy">
198
+ 60-second round. Hold ◀ / ▶ to move.
199
+ </div>
200
+ <button className="mgame__startBtn" onClick={start}>START</button>
201
+ </div>
202
+ </div>
203
+ )}
204
+ </div>
205
+
206
+ <div className="mgame__pad">
207
+ <button
208
+ type="button"
209
+ className="mgame__btn mgame__btn--left"
210
+ onPointerDown={press('left')}
211
+ onPointerUp={release('left')}
212
+ onPointerCancel={release('left')}
213
+ onPointerLeave={release('left')}
214
+ onContextMenu={(e) => e.preventDefault()}
215
+ aria-label="Move left"
216
+ >◀</button>
217
+ <button
218
+ type="button"
219
+ className="mgame__btn mgame__btn--right"
220
+ onPointerDown={press('right')}
221
+ onPointerUp={release('right')}
222
+ onPointerCancel={release('right')}
223
+ onPointerLeave={release('right')}
224
+ onContextMenu={(e) => e.preventDefault()}
225
+ aria-label="Move right"
226
+ >▶</button>
227
+ </div>
228
+ </section>
229
+ );
230
+ }
231
+
232
+ /* ---------- drawing ---------- */
233
+
234
+ function draw(ctx, s) {
235
+ const grad = ctx.createLinearGradient(0, 0, 0, GAME_H);
236
+ grad.addColorStop(0, '#cfe9ff');
237
+ grad.addColorStop(1, '#fde5b1');
238
+ ctx.fillStyle = grad;
239
+ ctx.fillRect(0, 0, GAME_W, GAME_H);
240
+
241
+ ctx.fillStyle = '#ffd86b';
242
+ pixelCircle(ctx, 580, 60, 22);
243
+
244
+ ctx.fillStyle = '#b8dca0';
245
+ ctx.fillRect(0, GROUND_Y, GAME_W, 22);
246
+ ctx.fillStyle = '#88b86c';
247
+ for (let x = 0; x < GAME_W; x += 18) ctx.fillRect(x, GROUND_Y, 8, 4);
248
+
249
+ for (const c of s.clouds) drawCloud(ctx, c);
250
+
251
+ for (const o of s.omelets) drawOmelet(ctx, o);
252
+ for (const cow of s.cows) drawCow(ctx, cow);
253
+
254
+ for (const e of s.eggs) drawEgg(ctx, e.x, e.y);
255
+ drawBasket(ctx, s.char.x, CHAR_TOP);
256
+
257
+ if (s.flash > 0) {
258
+ ctx.fillStyle = `rgba(255, 216, 107, ${s.flash * 0.06})`;
259
+ ctx.fillRect(0, 0, GAME_W, GAME_H);
260
+ }
261
+
262
+ drawTimeBar(ctx, s.timeLeft / ROUND_MS, s.running);
263
+
264
+ if (!s.running) {
265
+ ctx.fillStyle = 'rgba(26, 21, 48, 0.45)';
266
+ ctx.fillRect(0, 0, GAME_W, GAME_H);
267
+ }
268
+ }
269
+
270
+ function pixelCircle(ctx, cx, cy, r) {
271
+ for (let y = -r; y <= r; y += 2) {
272
+ const w = Math.floor(Math.sqrt(r * r - y * y));
273
+ ctx.fillRect(cx - w, cy + y, w * 2, 2);
274
+ }
275
+ }
276
+
277
+ function drawCloud(ctx, c) {
278
+ const { x, y, w, h } = c;
279
+ ctx.fillStyle = '#ffffff';
280
+ ctx.fillRect(x + 6, y, w - 12, h);
281
+ ctx.fillRect(x, y + 8, w, h - 16);
282
+ ctx.fillStyle = 'rgba(105, 116, 158, 0.35)';
283
+ ctx.fillRect(x + 6, y + h, w - 12, 4);
284
+ ctx.fillStyle = '#1a1530';
285
+ ctx.fillRect(x + Math.floor(w * 0.35), y + Math.floor(h * 0.45), 4, 4);
286
+ ctx.fillRect(x + Math.floor(w * 0.55), y + Math.floor(h * 0.45), 4, 4);
287
+ }
288
+
289
+ function drawEgg(ctx, cx, cy) {
290
+ ctx.fillStyle = 'rgba(26, 21, 48, 0.18)';
291
+ ctx.fillRect(cx - 8, cy + 12, 16, 3);
292
+ ctx.fillStyle = '#fff7f1';
293
+ ctx.fillRect(cx - 6, cy - 10, 12, 4);
294
+ ctx.fillRect(cx - 8, cy - 6, 16, 14);
295
+ ctx.fillRect(cx - 6, cy + 8, 12, 2);
296
+ ctx.fillStyle = '#ffd86b';
297
+ ctx.fillRect(cx + 1, cy - 2, 3, 3);
298
+ ctx.fillRect(cx - 4, cy + 2, 2, 2);
299
+ ctx.fillStyle = '#1a1530';
300
+ ctx.fillRect(cx - 6, cy - 11, 12, 1);
301
+ ctx.fillRect(cx - 6, cy + 9, 12, 1);
302
+ ctx.fillRect(cx - 9, cy - 6, 1, 14);
303
+ ctx.fillRect(cx + 8, cy - 6, 1, 14);
304
+ }
305
+
306
+ function drawOmelet(ctx, o) {
307
+ const { x, y, ttl } = o;
308
+ const fade = ttl < 1200 ? Math.max(0.35, ttl / 1200) : 1;
309
+ ctx.globalAlpha = fade;
310
+ ctx.fillStyle = '#fff7f1';
311
+ ctx.fillRect(x - 10, y - 2, 20, 4);
312
+ ctx.fillRect(x - 8, y - 4, 16, 2);
313
+ ctx.fillRect(x - 12, y, 24, 2);
314
+ ctx.fillStyle = '#ffd86b';
315
+ ctx.fillRect(x - 4, y - 2, 8, 4);
316
+ ctx.fillRect(x - 3, y - 3, 6, 1);
317
+ ctx.fillStyle = '#f0bf3a';
318
+ ctx.fillRect(x - 2, y - 1, 4, 2);
319
+ ctx.fillStyle = '#fff7f1';
320
+ ctx.fillRect(x - 14, y + 1, 2, 2);
321
+ ctx.fillRect(x + 12, y - 1, 2, 2);
322
+ ctx.fillStyle = '#1a1530';
323
+ ctx.fillRect(x - 12, y - 1, 1, 3);
324
+ ctx.fillRect(x + 11, y - 1, 1, 3);
325
+ ctx.globalAlpha = 1;
326
+ }
327
+
328
+ function drawCow(ctx, cow) {
329
+ const { x, dir, wobble } = cow;
330
+ const bob = Math.floor(wobble / 120) % 2;
331
+ const y = GROUND_Y - COW_H + 6 + bob;
332
+ const flip = dir < 0;
333
+ ctx.save();
334
+ if (flip) {
335
+ ctx.translate(x + COW_W, 0);
336
+ ctx.scale(-1, 1);
337
+ } else {
338
+ ctx.translate(x, 0);
339
+ }
340
+
341
+ ctx.fillStyle = '#fff7f1';
342
+ ctx.fillRect(4, y + 4, 22, 10);
343
+ ctx.fillRect(2, y + 6, 26, 6);
344
+ ctx.fillStyle = '#1a1530';
345
+ ctx.fillRect(7, y + 5, 4, 3);
346
+ ctx.fillRect(15, y + 8, 5, 3);
347
+ ctx.fillRect(20, y + 5, 3, 2);
348
+ ctx.fillStyle = '#fff7f1';
349
+ ctx.fillRect(22, y + 2, 8, 8);
350
+ ctx.fillRect(24, y, 4, 2);
351
+ ctx.fillStyle = '#ffb79d';
352
+ ctx.fillRect(28, y + 6, 3, 3);
353
+ ctx.fillStyle = '#1a1530';
354
+ ctx.fillRect(26, y + 4, 1, 1);
355
+ ctx.fillRect(23, y - 1, 1, 2);
356
+ ctx.fillRect(27, y - 1, 1, 2);
357
+ ctx.fillStyle = '#ffb79d';
358
+ ctx.fillRect(10, y + 13, 4, 3);
359
+ ctx.fillStyle = '#1a1530';
360
+ const legY = y + 14;
361
+ if (bob === 0) {
362
+ ctx.fillRect(5, legY, 2, 6);
363
+ ctx.fillRect(11, legY, 2, 4);
364
+ ctx.fillRect(17, legY, 2, 6);
365
+ ctx.fillRect(23, legY, 2, 4);
366
+ } else {
367
+ ctx.fillRect(5, legY, 2, 4);
368
+ ctx.fillRect(11, legY, 2, 6);
369
+ ctx.fillRect(17, legY, 2, 4);
370
+ ctx.fillRect(23, legY, 2, 6);
371
+ }
372
+ ctx.fillRect(2, y + 5, 1, 4);
373
+
374
+ ctx.fillStyle = '#1a1530';
375
+ ctx.fillRect(2, y + 6, 26, 1);
376
+ ctx.fillRect(2, y + 11, 26, 1);
377
+ ctx.fillRect(2, y + 6, 1, 6);
378
+ ctx.fillRect(27, y + 6, 1, 6);
379
+ ctx.fillRect(22, y + 2, 1, 8);
380
+ ctx.fillRect(30, y + 2, 1, 8);
381
+ ctx.fillRect(22, y + 1, 9, 1);
382
+ ctx.fillRect(22, y + 9, 9, 1);
383
+
384
+ ctx.restore();
385
+ }
386
+
387
+ function drawBasket(ctx, x, y) {
388
+ ctx.fillStyle = '#1a1530';
389
+ ctx.fillRect(x + 12, y - 14, CHAR_W - 24, 4);
390
+ ctx.fillRect(x + 8, y - 10, 4, 8);
391
+ ctx.fillRect(x + CHAR_W - 12, y - 10, 4, 8);
392
+
393
+ ctx.fillStyle = '#ff9573';
394
+ ctx.fillRect(x, y, CHAR_W, CHAR_H);
395
+ ctx.fillStyle = '#ffb79d';
396
+ ctx.fillRect(x + 4, y + 4, CHAR_W - 8, 6);
397
+ ctx.fillStyle = '#ff9573';
398
+ for (let i = 0; i < CHAR_W; i += 8) {
399
+ ctx.fillRect(x + i, y + 12, 4, 4);
400
+ ctx.fillRect(x + i + 4, y + 20, 4, 4);
401
+ ctx.fillRect(x + i, y + 28, 4, 4);
402
+ }
403
+ ctx.fillStyle = '#1a1530';
404
+ ctx.fillRect(x, y, CHAR_W, 2);
405
+ ctx.fillRect(x, y + CHAR_H - 2, CHAR_W, 2);
406
+ ctx.fillRect(x, y, 2, CHAR_H);
407
+ ctx.fillRect(x + CHAR_W - 2, y, 2, CHAR_H);
408
+ }
409
+
410
+ function drawTimeBar(ctx, ratio, running) {
411
+ const ax = 8;
412
+ const ay = 22;
413
+ const aw = 16;
414
+ const ah = GAME_H - 48;
415
+ const inner = 3;
416
+ const innerH = ah - inner * 2;
417
+ const fillH = Math.max(0, Math.min(innerH, Math.floor(innerH * ratio)));
418
+
419
+ ctx.fillStyle = '#1a1530';
420
+ ctx.fillRect(ax, ay, aw, ah);
421
+ ctx.fillStyle = '#fff7f1';
422
+ ctx.fillRect(ax + inner, ay + inner, aw - inner * 2, ah - inner * 2);
423
+
424
+ const waterColor = ratio > 0.4 ? '#5db7e8' : ratio > 0.18 ? '#ffd86b' : '#ff9573';
425
+ ctx.fillStyle = waterColor;
426
+ ctx.fillRect(ax + inner, ay + inner + (innerH - fillH), aw - inner * 2, fillH);
427
+
428
+ if (fillH > 2 && running) {
429
+ const surfaceY = ay + inner + (innerH - fillH);
430
+ ctx.fillStyle = '#1a1530';
431
+ ctx.fillRect(ax + inner, surfaceY, aw - inner * 2, 1);
432
+ ctx.fillStyle = '#ffffff';
433
+ const t = Math.floor(performance.now() / 200) % 2;
434
+ ctx.fillRect(ax + inner + (t ? 0 : 4), surfaceY + 1, 4, 1);
435
+ }
436
+
437
+ ctx.fillStyle = '#1a1530';
438
+ for (let i = 1; i < 4; i++) {
439
+ const ty = ay + inner + Math.floor((innerH * i) / 4);
440
+ ctx.fillRect(ax - 2, ty, 3, 1);
441
+ }
442
+ }
src/UsernameGate.jsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function UsernameGate({ onPick, isMobile }) {
4
+ const [value, setValue] = React.useState('');
5
+ const [error, setError] = React.useState('');
6
+ const [busy, setBusy] = React.useState(false);
7
+ const [info, setInfo] = React.useState(null); // { exists, best }
8
+
9
+ const validate = (v) => /^[A-Za-z0-9_\-]{2,16}$/.test(v);
10
+
11
+ const submit = async (e) => {
12
+ e?.preventDefault();
13
+ const name = value.trim();
14
+ if (!validate(name)) {
15
+ setError('2–16 chars. Letters, numbers, _ or - only.');
16
+ return;
17
+ }
18
+ if (!isMobile) {
19
+ try { document.documentElement.requestFullscreen?.(); } catch {}
20
+ }
21
+ setBusy(true);
22
+ setError('');
23
+ try {
24
+ const r = await fetch('/api/user/check', {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify({ username: name }),
28
+ }).then((r) => r.json());
29
+ if (!r.ok) throw new Error(r.error || 'check failed');
30
+ setInfo({ exists: r.exists, best: r.best });
31
+ onPick(name);
32
+ } catch (err) {
33
+ setError(err.message || 'Could not verify username. Try again.');
34
+ } finally {
35
+ setBusy(false);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <div className="gate">
41
+ <div className="gate__panel">
42
+ <div className="gate__eyebrow">▸ ARCADE / EGG-CATCHER</div>
43
+ <h1 className="gate__title">PICK YOUR <span className="accent-y">NAME</span></h1>
44
+ <p className="gate__copy">
45
+ Your best score will be saved to the global leaderboard.
46
+ New name? You start at zero. Returning? We&rsquo;ll load your best.
47
+ </p>
48
+
49
+ <form className="gate__form" onSubmit={submit}>
50
+ <label className="gate__label" htmlFor="u">Username</label>
51
+ <input
52
+ id="u"
53
+ className="gate__input"
54
+ value={value}
55
+ onChange={(e) => setValue(e.target.value)}
56
+ placeholder="e.g. pixel_pirate"
57
+ maxLength={16}
58
+ autoFocus
59
+ autoComplete="off"
60
+ spellCheck="false"
61
+ />
62
+ <div className="gate__hint">2–16 characters. Letters, numbers, _ or - only.</div>
63
+ {error && <div className="gate__err">✕ {error}</div>}
64
+ {info?.exists && !error && (
65
+ <div className="gate__ok">★ Welcome back — current best {info.best}</div>
66
+ )}
67
+ <button type="submit" className="gate__btn" disabled={busy}>
68
+ {busy ? 'CHECKING…' : 'ENTER ARCADE ▸'}
69
+ </button>
70
+ </form>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
src/main.jsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App.jsx';
4
+ import Controller from './Controller.jsx';
5
+ import './styles.css';
6
+
7
+ const path = window.location.pathname.replace(/\/+$/, '');
8
+ const isController = path === '/controller';
9
+
10
+ ReactDOM.createRoot(document.getElementById('root')).render(
11
+ <React.StrictMode>
12
+ {isController ? <Controller /> : <App />}
13
+ </React.StrictMode>,
14
+ );
src/styles.css ADDED
@@ -0,0 +1,1242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ egg-catcher — standalone arcade
3
+ ============================================ */
4
+
5
+ :root {
6
+ --blue: #5db7e8;
7
+ --blue-deep: #3d97c8;
8
+ --purple: #b9a5f1;
9
+ --purple-deep: #9682d9;
10
+ --purple-night: #6b54bd;
11
+ --coral: #ffb79d;
12
+ --coral-deep: #ff9573;
13
+ --yellow: #ffd86b;
14
+ --yellow-deep: #f0bf3a;
15
+ --green: #b8dca0;
16
+ --green-deep: #88b86c;
17
+ --mint: #fde5b1;
18
+ --cream: #fff7f1;
19
+ --cream-2: #faecd9;
20
+ --ink: #1a1530;
21
+ --ink-soft: #4a3f6b;
22
+ --ink-mute: #7a6f95;
23
+
24
+ --font-pixel: 'Press Start 2P', 'VT323', monospace;
25
+ --font-mono: 'IBM Plex Mono', 'Courier New', monospace;
26
+ }
27
+
28
+ *, *::before, *::after { box-sizing: border-box; }
29
+ html, body { margin: 0; padding: 0; }
30
+ body {
31
+ font-family: var(--font-mono);
32
+ font-size: 15px;
33
+ line-height: 1.6;
34
+ color: var(--ink);
35
+ background: var(--cream);
36
+ -webkit-font-smoothing: antialiased;
37
+ min-height: 100vh;
38
+ }
39
+ body::before {
40
+ content: '';
41
+ position: fixed;
42
+ inset: 0;
43
+ pointer-events: none;
44
+ z-index: 0;
45
+ background-image: radial-gradient(rgba(26, 21, 48, 0.06) 1.5px, transparent 1.5px);
46
+ background-size: 18px 18px;
47
+ }
48
+
49
+ button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; padding: 0; }
50
+ img { max-width: 100%; display: block; }
51
+
52
+ h1, h2, h3 {
53
+ font-family: var(--font-pixel);
54
+ letter-spacing: 0.02em;
55
+ text-transform: uppercase;
56
+ margin: 0;
57
+ line-height: 1.18;
58
+ }
59
+
60
+ .accent-y { color: var(--yellow-deep); }
61
+ .accent-c { color: var(--coral-deep); }
62
+ .accent-p { color: var(--purple-deep); }
63
+ .accent-b { color: var(--blue-deep); }
64
+
65
+ .eyebrow {
66
+ font-family: var(--font-pixel);
67
+ font-size: 10px;
68
+ letter-spacing: 0.18em;
69
+ color: var(--purple-night);
70
+ text-transform: uppercase;
71
+ }
72
+
73
+ /* ============================================
74
+ top bar
75
+ ============================================ */
76
+
77
+ .app-shell {
78
+ position: relative;
79
+ z-index: 1;
80
+ min-height: 100vh;
81
+ padding-bottom: 40px;
82
+ }
83
+
84
+ .topbar {
85
+ display: grid;
86
+ grid-template-columns: auto 1fr auto;
87
+ align-items: center;
88
+ gap: 24px;
89
+ padding: 18px clamp(16px, 4vw, 40px);
90
+ background: var(--cream);
91
+ border-bottom: 3px solid var(--ink);
92
+ position: sticky;
93
+ top: 0;
94
+ z-index: 10;
95
+ }
96
+
97
+ .topbar__brand {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ gap: 12px;
101
+ }
102
+
103
+ .topbar__logo {
104
+ border: 3px solid var(--ink);
105
+ box-shadow: 4px 4px 0 var(--ink);
106
+ background: var(--yellow);
107
+ padding: 8px 12px 7px;
108
+ display: inline-flex;
109
+ gap: 1px;
110
+ font-family: var(--font-pixel);
111
+ font-size: 16px;
112
+ }
113
+ .topbar__logo > span:nth-child(1) { color: var(--coral-deep); }
114
+ .topbar__logo > span:nth-child(2) { color: var(--purple-deep); }
115
+ .topbar__logo > span:nth-child(3) { color: var(--blue-deep); }
116
+
117
+ .topbar__title {
118
+ font-family: var(--font-pixel);
119
+ font-size: 14px;
120
+ letter-spacing: 0.08em;
121
+ }
122
+
123
+ .topbar__user {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 2px;
127
+ justify-self: center;
128
+ align-items: center;
129
+ }
130
+ .topbar__userTag {
131
+ font-family: var(--font-pixel);
132
+ font-size: 9px;
133
+ letter-spacing: 0.16em;
134
+ color: var(--ink-mute);
135
+ }
136
+ .topbar__userName {
137
+ font-family: var(--font-pixel);
138
+ font-size: 13px;
139
+ color: var(--ink);
140
+ letter-spacing: 0.05em;
141
+ }
142
+
143
+ .topbar__actions {
144
+ display: flex;
145
+ gap: 10px;
146
+ flex-wrap: wrap;
147
+ justify-content: flex-end;
148
+ }
149
+
150
+ .tbtn {
151
+ font-family: var(--font-pixel);
152
+ font-size: 10px;
153
+ letter-spacing: 0.1em;
154
+ padding: 10px 14px 9px;
155
+ background: var(--yellow);
156
+ color: var(--ink);
157
+ border: 3px solid var(--ink);
158
+ box-shadow: 4px 4px 0 var(--ink);
159
+ transition: transform 90ms, box-shadow 90ms;
160
+ }
161
+ .tbtn:hover { transform: translate(-1px, -1px); box-shadow: 5px 5px 0 var(--ink); }
162
+ .tbtn:active { transform: translate(4px, 4px); box-shadow: 0 0 0 var(--ink); }
163
+ .tbtn--alt { background: var(--blue); }
164
+ .tbtn--ghost { background: var(--cream); }
165
+
166
+ @media (max-width: 720px) {
167
+ .topbar { grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px; }
168
+ .topbar__user { display: none; }
169
+ .topbar__actions { gap: 8px; }
170
+ .tbtn { font-size: 9px; padding: 8px 10px 7px; }
171
+ }
172
+
173
+ /* ============================================
174
+ username gate
175
+ ============================================ */
176
+
177
+ .gate {
178
+ min-height: 100vh;
179
+ display: grid;
180
+ place-items: center;
181
+ padding: 30px 18px;
182
+ position: relative;
183
+ z-index: 1;
184
+ }
185
+
186
+ .gate__panel {
187
+ width: 100%;
188
+ max-width: 480px;
189
+ background: var(--cream);
190
+ border: 4px solid var(--ink);
191
+ box-shadow: 12px 12px 0 var(--ink);
192
+ padding: 32px 30px;
193
+ display: flex;
194
+ flex-direction: column;
195
+ gap: 14px;
196
+ }
197
+
198
+ .gate__eyebrow {
199
+ font-family: var(--font-pixel);
200
+ font-size: 9px;
201
+ letter-spacing: 0.16em;
202
+ color: var(--purple-night);
203
+ }
204
+
205
+ .gate__title {
206
+ font-family: var(--font-pixel);
207
+ font-size: clamp(22px, 4vw, 28px);
208
+ letter-spacing: 0.04em;
209
+ margin: 0;
210
+ }
211
+
212
+ .gate__copy {
213
+ font-family: var(--font-mono);
214
+ font-size: 14px;
215
+ color: var(--ink-soft);
216
+ line-height: 1.6;
217
+ margin: 0;
218
+ }
219
+
220
+ .gate__form {
221
+ display: flex;
222
+ flex-direction: column;
223
+ gap: 8px;
224
+ margin-top: 6px;
225
+ }
226
+
227
+ .gate__label {
228
+ font-family: var(--font-pixel);
229
+ font-size: 9px;
230
+ letter-spacing: 0.14em;
231
+ color: var(--ink-soft);
232
+ }
233
+
234
+ .gate__input {
235
+ font-family: var(--font-mono);
236
+ font-size: 18px;
237
+ color: var(--ink);
238
+ background: var(--cream);
239
+ border: 3px solid var(--ink);
240
+ padding: 14px 16px;
241
+ outline: none;
242
+ box-shadow: 5px 5px 0 var(--ink);
243
+ transition: transform 120ms, box-shadow 120ms;
244
+ }
245
+ .gate__input:focus {
246
+ transform: translate(-1px, -1px);
247
+ box-shadow: 6px 6px 0 var(--purple);
248
+ }
249
+
250
+ .gate__hint {
251
+ font-family: var(--font-mono);
252
+ font-size: 12px;
253
+ color: var(--ink-mute);
254
+ }
255
+
256
+ .gate__err {
257
+ font-family: var(--font-pixel);
258
+ font-size: 10px;
259
+ color: var(--coral-deep);
260
+ background: var(--coral);
261
+ border: 2px solid var(--ink);
262
+ padding: 8px 10px;
263
+ letter-spacing: 0.04em;
264
+ color: var(--ink);
265
+ }
266
+
267
+ .gate__ok {
268
+ font-family: var(--font-pixel);
269
+ font-size: 10px;
270
+ background: var(--green);
271
+ border: 2px solid var(--ink);
272
+ padding: 8px 10px;
273
+ letter-spacing: 0.04em;
274
+ color: var(--ink);
275
+ }
276
+
277
+ .gate__btn {
278
+ margin-top: 10px;
279
+ font-family: var(--font-pixel);
280
+ font-size: 12px;
281
+ letter-spacing: 0.1em;
282
+ padding: 16px 18px;
283
+ background: var(--yellow);
284
+ border: 3px solid var(--ink);
285
+ box-shadow: 6px 6px 0 var(--ink);
286
+ color: var(--ink);
287
+ transition: transform 90ms, box-shadow 90ms;
288
+ }
289
+ .gate__btn:hover { transform: translate(-1px, -1px); box-shadow: 7px 7px 0 var(--ink); }
290
+ .gate__btn:active { transform: translate(6px, 6px); box-shadow: 0 0 0 var(--ink); }
291
+ .gate__btn:disabled { opacity: 0.7; cursor: wait; }
292
+
293
+ /* ============================================
294
+ game (laptop)
295
+ ============================================ */
296
+
297
+ .btn {
298
+ font-family: var(--font-pixel);
299
+ font-size: 11px;
300
+ letter-spacing: 0.06em;
301
+ padding: 14px 22px 13px;
302
+ background: var(--yellow);
303
+ color: var(--ink);
304
+ border: 3px solid var(--ink);
305
+ box-shadow: 6px 6px 0 var(--ink);
306
+ transition: transform 90ms, box-shadow 90ms;
307
+ }
308
+ .btn:hover { transform: translate(-1px, -1px); box-shadow: 7px 7px 0 var(--ink); }
309
+ .btn:active { transform: translate(6px, 6px); box-shadow: 0 0 0 var(--ink); }
310
+ .btn--yellow { background: var(--yellow); }
311
+ .btn--lg { font-size: 13px; padding: 16px 26px; }
312
+
313
+ .game {
314
+ padding: 36px clamp(16px, 4vw, 40px);
315
+ position: relative;
316
+ z-index: 1;
317
+ max-width: 1240px;
318
+ margin: 0 auto;
319
+ }
320
+
321
+ .game__head {
322
+ margin-bottom: 24px;
323
+ display: flex;
324
+ flex-direction: column;
325
+ gap: 8px;
326
+ text-align: center;
327
+ align-items: center;
328
+ }
329
+
330
+ .game__title {
331
+ font-size: clamp(22px, 3.4vw, 32px);
332
+ }
333
+
334
+ .game__lede {
335
+ font-family: var(--font-mono);
336
+ font-size: 14px;
337
+ color: var(--ink-soft);
338
+ max-width: 600px;
339
+ margin: 0;
340
+ }
341
+
342
+ .game--solo .game__stage { max-width: 1100px; margin: 0 auto; }
343
+
344
+ .game__grid {
345
+ display: grid;
346
+ grid-template-columns: minmax(0, 1fr) 320px;
347
+ gap: 28px;
348
+ align-items: stretch;
349
+ }
350
+
351
+ .game__stage {
352
+ background: var(--cream-2);
353
+ border: 4px solid var(--ink);
354
+ box-shadow: 8px 8px 0 var(--ink);
355
+ padding: 16px;
356
+ display: flex;
357
+ flex-direction: column;
358
+ gap: 14px;
359
+ position: relative;
360
+ }
361
+
362
+ .game__hud {
363
+ display: grid;
364
+ grid-template-columns: 1fr 1fr 1.4fr;
365
+ gap: 10px;
366
+ }
367
+
368
+ .game__hudCell {
369
+ background: var(--cream);
370
+ border: 3px solid var(--ink);
371
+ padding: 10px 12px;
372
+ display: flex;
373
+ flex-direction: column;
374
+ gap: 4px;
375
+ box-shadow: 3px 3px 0 var(--ink);
376
+ }
377
+
378
+ .game__hudCell--actions {
379
+ flex-direction: row;
380
+ align-items: center;
381
+ justify-content: flex-end;
382
+ gap: 8px;
383
+ }
384
+
385
+ .game__hudLabel {
386
+ font-family: var(--font-pixel);
387
+ font-size: 9px;
388
+ letter-spacing: 0.12em;
389
+ color: var(--ink-mute);
390
+ }
391
+
392
+ .game__hudNum {
393
+ font-family: var(--font-pixel);
394
+ font-size: 18px;
395
+ color: var(--ink);
396
+ }
397
+ .game__hudNum--sm { font-size: 12px; word-break: break-all; }
398
+
399
+ .game__iconBtn {
400
+ font-family: var(--font-pixel);
401
+ font-size: 9px;
402
+ letter-spacing: 0.1em;
403
+ padding: 8px 10px;
404
+ background: var(--yellow);
405
+ border: 2px solid var(--ink);
406
+ box-shadow: 2px 2px 0 var(--ink);
407
+ color: var(--ink);
408
+ transition: transform 0.05s, box-shadow 0.05s;
409
+ }
410
+ .game__iconBtn--alt { background: var(--blue); }
411
+ .game__iconBtn:active { transform: translate(1px, 1px); box-shadow: 1px 1px 0 var(--ink); }
412
+
413
+ .game__canvasWrap {
414
+ position: relative;
415
+ background: #cfe9ff;
416
+ border: 3px solid var(--ink);
417
+ box-shadow: 4px 4px 0 var(--ink);
418
+ overflow: hidden;
419
+ aspect-ratio: 640 / 380;
420
+ }
421
+
422
+ .game__canvas {
423
+ display: block;
424
+ width: 100%;
425
+ height: 100%;
426
+ image-rendering: pixelated;
427
+ }
428
+
429
+ .game__stage:fullscreen {
430
+ background: #1a1530;
431
+ padding: 0;
432
+ border: none;
433
+ box-shadow: none;
434
+ display: flex;
435
+ align-items: center;
436
+ justify-content: center;
437
+ flex-direction: column;
438
+ width: 100vw;
439
+ height: 100vh;
440
+ gap: 0;
441
+ }
442
+
443
+ .game__stage:fullscreen .game__hud,
444
+ .game__stage:fullscreen .game__foot {
445
+ position: absolute;
446
+ z-index: 2;
447
+ background: rgba(255, 247, 241, 0.92);
448
+ border: 3px solid var(--ink);
449
+ box-shadow: 3px 3px 0 var(--ink);
450
+ padding: 8px 10px;
451
+ }
452
+
453
+ .game__stage:fullscreen .game__hud {
454
+ top: 16px;
455
+ left: 16px;
456
+ display: flex;
457
+ gap: 10px;
458
+ }
459
+ .game__stage:fullscreen .game__hud .game__hudCell {
460
+ background: transparent;
461
+ border: none;
462
+ box-shadow: none;
463
+ padding: 2px 8px;
464
+ flex-direction: row;
465
+ align-items: center;
466
+ gap: 8px;
467
+ }
468
+ .game__stage:fullscreen .game__foot { bottom: 16px; left: 16px; display: flex; gap: 8px; }
469
+ .game__stage:fullscreen .game__canvasWrap {
470
+ border: none;
471
+ box-shadow: none;
472
+ width: min(100vw, calc(100vh * 640 / 380));
473
+ height: min(100vh, calc(100vw * 380 / 640));
474
+ aspect-ratio: 640 / 380;
475
+ }
476
+
477
+ .game__overlay {
478
+ position: absolute;
479
+ inset: 0;
480
+ display: flex;
481
+ align-items: center;
482
+ justify-content: center;
483
+ background: rgba(26, 21, 48, 0.55);
484
+ }
485
+
486
+ .game__overlayInner {
487
+ background: var(--cream);
488
+ border: 4px solid var(--ink);
489
+ box-shadow: 6px 6px 0 var(--ink);
490
+ padding: 22px 28px;
491
+ text-align: center;
492
+ display: flex;
493
+ flex-direction: column;
494
+ gap: 12px;
495
+ align-items: center;
496
+ max-width: 80%;
497
+ }
498
+
499
+ .game__overlayTitle {
500
+ font-family: var(--font-pixel);
501
+ font-size: 18px;
502
+ color: var(--ink);
503
+ }
504
+ .game__overlayCopy {
505
+ font-family: var(--font-mono);
506
+ font-size: 14px;
507
+ color: var(--ink-soft);
508
+ }
509
+
510
+ .game__foot { display: flex; gap: 10px; flex-wrap: wrap; }
511
+ .game__pill {
512
+ font-family: var(--font-pixel);
513
+ font-size: 9px;
514
+ letter-spacing: 0.1em;
515
+ padding: 8px 12px;
516
+ background: var(--cream);
517
+ border: 3px solid var(--ink);
518
+ box-shadow: 3px 3px 0 var(--ink);
519
+ color: var(--ink-mute);
520
+ }
521
+ .game__pill.is-on { background: var(--green); color: var(--ink); }
522
+ .game__pill--muted { color: var(--ink); }
523
+
524
+ /* QR side */
525
+ .game__qr { display: flex; }
526
+ .game__qrPanel {
527
+ flex: 1;
528
+ background: var(--cream);
529
+ border: 4px solid var(--ink);
530
+ box-shadow: 8px 8px 0 var(--ink);
531
+ padding: 18px;
532
+ display: flex;
533
+ flex-direction: column;
534
+ gap: 14px;
535
+ }
536
+ .game__qrHead { display: flex; align-items: center; gap: 8px; }
537
+ .game__qrDot { width: 10px; height: 10px; background: var(--coral-deep); border: 2px solid var(--ink); }
538
+ .game__qrTitle { font-family: var(--font-pixel); font-size: 10px; letter-spacing: 0.12em; color: var(--ink); }
539
+ .game__qrCode {
540
+ background: var(--cream-2);
541
+ border: 3px solid var(--ink);
542
+ box-shadow: 4px 4px 0 var(--ink);
543
+ padding: 10px;
544
+ display: flex;
545
+ align-items: center;
546
+ justify-content: center;
547
+ aspect-ratio: 1;
548
+ }
549
+ .game__qrCode img { width: 100%; height: 100%; image-rendering: pixelated; }
550
+ .game__qrSkeleton { font-family: var(--font-pixel); font-size: 10px; color: var(--ink-mute); }
551
+ .game__qrSteps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; font-family: var(--font-mono); font-size: 13px; color: var(--ink-soft); }
552
+ .game__qrSteps li { display: flex; align-items: center; gap: 10px; }
553
+ .game__stepNum {
554
+ flex-shrink: 0;
555
+ width: 22px; height: 22px;
556
+ display: inline-flex;
557
+ align-items: center;
558
+ justify-content: center;
559
+ background: var(--yellow);
560
+ border: 2px solid var(--ink);
561
+ font-family: var(--font-pixel);
562
+ font-size: 9px;
563
+ }
564
+ .game__qrUrl {
565
+ background: var(--ink);
566
+ color: var(--cream);
567
+ padding: 8px 10px;
568
+ border: 2px solid var(--ink);
569
+ font-family: var(--font-mono);
570
+ font-size: 11px;
571
+ word-break: break-all;
572
+ }
573
+ .game__qrNote { margin: 0; font-family: var(--font-mono); font-size: 12px; color: var(--ink-mute); }
574
+ .game__ipPick { display: flex; flex-direction: column; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); }
575
+ .game__ipPick select { font-family: var(--font-mono); font-size: 12px; background: var(--cream-2); border: 2px solid var(--ink); padding: 6px 8px; color: var(--ink); }
576
+
577
+ @media (max-width: 980px) {
578
+ .game__grid { grid-template-columns: 1fr; }
579
+ .game__qrPanel { max-width: 360px; margin: 0 auto; }
580
+ }
581
+
582
+ /* ============================================
583
+ MOBILE GAME (standalone)
584
+ ============================================ */
585
+
586
+ .mgame {
587
+ position: fixed;
588
+ inset: 0;
589
+ top: 64px; /* sit below sticky topbar */
590
+ background: linear-gradient(180deg, #1a1530 0%, #2a2147 100%);
591
+ display: flex;
592
+ flex-direction: column;
593
+ padding: 10px;
594
+ gap: 10px;
595
+ z-index: 1;
596
+ }
597
+
598
+ .mgame__hud {
599
+ display: grid;
600
+ grid-template-columns: 1fr 1fr 1.5fr;
601
+ gap: 8px;
602
+ }
603
+
604
+ .mgame__hudCell {
605
+ background: var(--cream);
606
+ border: 3px solid var(--ink);
607
+ padding: 8px 10px;
608
+ display: flex;
609
+ flex-direction: column;
610
+ gap: 2px;
611
+ box-shadow: 3px 3px 0 #000;
612
+ }
613
+
614
+ .mgame__hudLabel { font-family: var(--font-pixel); font-size: 8px; color: var(--ink-mute); letter-spacing: 0.12em; }
615
+ .mgame__hudNum { font-family: var(--font-pixel); font-size: 16px; color: var(--ink); letter-spacing: 0.04em; }
616
+ .mgame__hudNum--sm { font-size: 12px; word-break: break-all; }
617
+
618
+ .mgame__canvasWrap {
619
+ position: relative;
620
+ flex: none;
621
+ background: #cfe9ff;
622
+ border: 3px solid var(--ink);
623
+ box-shadow: 4px 4px 0 #000;
624
+ overflow: hidden;
625
+ aspect-ratio: 640 / 380;
626
+ width: 100%;
627
+ }
628
+
629
+ .mgame__canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
630
+
631
+ .mgame__overlay {
632
+ position: absolute;
633
+ inset: 0;
634
+ background: rgba(26, 21, 48, 0.6);
635
+ display: flex;
636
+ align-items: center;
637
+ justify-content: center;
638
+ }
639
+ .mgame__overlayInner {
640
+ background: var(--cream);
641
+ border: 3px solid var(--ink);
642
+ box-shadow: 4px 4px 0 #000;
643
+ padding: 16px 18px;
644
+ display: flex;
645
+ flex-direction: column;
646
+ gap: 10px;
647
+ align-items: center;
648
+ text-align: center;
649
+ max-width: 84%;
650
+ }
651
+ .mgame__overlayTitle { font-family: var(--font-pixel); font-size: 14px; }
652
+ .mgame__overlayCopy { font-family: var(--font-mono); font-size: 13px; color: var(--ink-soft); }
653
+ .mgame__startBtn {
654
+ font-family: var(--font-pixel);
655
+ font-size: 12px;
656
+ padding: 12px 22px;
657
+ background: var(--yellow);
658
+ border: 3px solid var(--ink);
659
+ box-shadow: 4px 4px 0 var(--ink);
660
+ letter-spacing: 0.08em;
661
+ }
662
+
663
+ .mgame__pad {
664
+ flex: 0 0 auto;
665
+ height: min(28vh, 180px);
666
+ display: grid;
667
+ grid-template-columns: 1fr 1fr;
668
+ gap: 10px;
669
+ min-height: 0;
670
+ }
671
+
672
+ .mgame__btn {
673
+ font-family: var(--font-pixel);
674
+ font-size: clamp(28px, 7vh, 56px);
675
+ color: var(--ink);
676
+ background: var(--yellow);
677
+ border: 3px solid var(--ink);
678
+ box-shadow: 4px 4px 0 #000;
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: center;
682
+ touch-action: manipulation;
683
+ -webkit-tap-highlight-color: transparent;
684
+ user-select: none;
685
+ }
686
+ .mgame__btn--left { background: var(--blue); }
687
+ .mgame__btn--right { background: var(--coral); }
688
+ .mgame__btn:active {
689
+ transform: translate(3px, 3px);
690
+ box-shadow: 2px 2px 0 #000;
691
+ background: var(--green);
692
+ }
693
+
694
+ /* ============================================
695
+ LEADERBOARD
696
+ ============================================ */
697
+
698
+ .lb {
699
+ min-height: 100vh;
700
+ padding: 30px clamp(16px, 4vw, 40px);
701
+ position: relative;
702
+ z-index: 1;
703
+ }
704
+
705
+ .lb__panel {
706
+ max-width: 880px;
707
+ margin: 0 auto;
708
+ background: var(--cream);
709
+ border: 4px solid var(--ink);
710
+ box-shadow: 12px 12px 0 var(--ink);
711
+ padding: 28px clamp(20px, 4vw, 36px);
712
+ display: flex;
713
+ flex-direction: column;
714
+ gap: 18px;
715
+ }
716
+
717
+ .lb__head {
718
+ display: flex;
719
+ align-items: flex-end;
720
+ justify-content: space-between;
721
+ gap: 16px;
722
+ flex-wrap: wrap;
723
+ }
724
+
725
+ .lb__title { font-size: clamp(22px, 3.4vw, 30px); }
726
+
727
+ .lb__actions { display: flex; gap: 8px; flex-wrap: wrap; }
728
+
729
+ .lb__resultCard {
730
+ display: grid;
731
+ grid-template-columns: repeat(3, 1fr);
732
+ gap: 12px;
733
+ background: var(--mint);
734
+ border: 3px solid var(--ink);
735
+ box-shadow: 5px 5px 0 var(--ink);
736
+ padding: 16px;
737
+ }
738
+
739
+ .lb__resultCell {
740
+ display: flex;
741
+ flex-direction: column;
742
+ gap: 4px;
743
+ align-items: flex-start;
744
+ }
745
+
746
+ .lb__resultLabel {
747
+ font-family: var(--font-pixel);
748
+ font-size: 9px;
749
+ letter-spacing: 0.14em;
750
+ color: var(--ink-mute);
751
+ }
752
+
753
+ .lb__resultNum {
754
+ font-family: var(--font-pixel);
755
+ font-size: 22px;
756
+ color: var(--ink);
757
+ }
758
+
759
+ .lb__resultSub {
760
+ font-family: var(--font-mono);
761
+ font-size: 12px;
762
+ color: var(--ink-mute);
763
+ font-weight: 400;
764
+ margin-left: 4px;
765
+ }
766
+
767
+ .lb__loading, .lb__err, .lb__empty {
768
+ font-family: var(--font-pixel);
769
+ font-size: 10px;
770
+ letter-spacing: 0.06em;
771
+ padding: 12px;
772
+ text-align: center;
773
+ color: var(--ink-soft);
774
+ }
775
+ .lb__err { color: var(--ink); background: var(--coral); border: 2px solid var(--ink); }
776
+
777
+ .lb__tableHead, .lb__row {
778
+ display: grid;
779
+ grid-template-columns: 60px 1fr 80px 90px;
780
+ align-items: center;
781
+ gap: 10px;
782
+ }
783
+
784
+ .lb__tableHead {
785
+ font-family: var(--font-pixel);
786
+ font-size: 9px;
787
+ letter-spacing: 0.14em;
788
+ color: var(--ink-mute);
789
+ padding: 0 14px;
790
+ }
791
+
792
+ .lb__col { display: flex; align-items: center; }
793
+ .lb__col--score, .lb__col--games { justify-content: flex-end; }
794
+
795
+ .lb__rows {
796
+ list-style: none;
797
+ margin: 0;
798
+ padding: 0;
799
+ display: flex;
800
+ flex-direction: column;
801
+ gap: 8px;
802
+ }
803
+ .lb__rows--solo { margin-top: 6px; }
804
+
805
+ .lb__row {
806
+ background: var(--cream);
807
+ border: 3px solid var(--ink);
808
+ box-shadow: 4px 4px 0 var(--ink);
809
+ padding: 12px 14px;
810
+ font-family: var(--font-mono);
811
+ font-size: 14px;
812
+ }
813
+
814
+ .lb__row.is-me {
815
+ background: var(--yellow);
816
+ box-shadow: 4px 4px 0 var(--purple-night);
817
+ }
818
+
819
+ .lb__rank {
820
+ font-family: var(--font-pixel);
821
+ font-size: 14px;
822
+ color: var(--ink);
823
+ }
824
+ .lb__rank--1 { color: var(--yellow-deep); text-shadow: 1px 1px 0 var(--ink); }
825
+ .lb__rank--2 { color: var(--purple-deep); }
826
+ .lb__rank--3 { color: var(--coral-deep); }
827
+
828
+ .lb__col--name { font-weight: 600; gap: 8px; }
829
+
830
+ .lb__mePill {
831
+ margin-left: 8px;
832
+ font-family: var(--font-pixel);
833
+ font-size: 8px;
834
+ letter-spacing: 0.1em;
835
+ background: var(--ink);
836
+ color: var(--mint);
837
+ padding: 3px 6px;
838
+ border: 2px solid var(--ink);
839
+ }
840
+
841
+ .lb__col--score {
842
+ font-family: var(--font-pixel);
843
+ font-size: 14px;
844
+ }
845
+
846
+ .lb__dots {
847
+ text-align: center;
848
+ font-family: var(--font-pixel);
849
+ color: var(--ink-mute);
850
+ letter-spacing: 0.4em;
851
+ }
852
+
853
+ @media (max-width: 600px) {
854
+ .lb__tableHead, .lb__row { grid-template-columns: 40px 1fr 64px; }
855
+ .lb__col--games { display: none; }
856
+ .lb__resultCard { grid-template-columns: 1fr; }
857
+ }
858
+
859
+ /* ============================================
860
+ CONTROLLER (phone, landscape)
861
+ ============================================ */
862
+
863
+ body.controller-body {
864
+ background: var(--ink);
865
+ overflow: hidden;
866
+ touch-action: none;
867
+ margin: 0;
868
+ }
869
+
870
+ .ctrl {
871
+ position: fixed;
872
+ inset: 0;
873
+ background: linear-gradient(180deg, #1a1530 0%, #2a2147 100%);
874
+ color: var(--cream);
875
+ display: flex;
876
+ flex-direction: column;
877
+ padding: 10px;
878
+ gap: 10px;
879
+ user-select: none;
880
+ -webkit-tap-highlight-color: transparent;
881
+ }
882
+
883
+ .ctrl--prep, .ctrl--rotate {
884
+ align-items: center;
885
+ justify-content: center;
886
+ }
887
+
888
+ .ctrl__panel, .ctrl__prep {
889
+ margin: auto;
890
+ background: var(--cream);
891
+ color: var(--ink);
892
+ border: 4px solid var(--ink);
893
+ box-shadow: 6px 6px 0 #000;
894
+ padding: 22px 26px;
895
+ text-align: center;
896
+ max-width: 380px;
897
+ display: flex;
898
+ flex-direction: column;
899
+ gap: 10px;
900
+ align-items: center;
901
+ }
902
+
903
+ .ctrl__title, .ctrl__prepTitle {
904
+ font-family: var(--font-pixel);
905
+ font-size: 16px;
906
+ letter-spacing: 0.08em;
907
+ }
908
+
909
+ .ctrl__copy, .ctrl__prepCopy {
910
+ font-family: var(--font-mono);
911
+ font-size: 14px;
912
+ color: var(--ink-soft);
913
+ margin: 0;
914
+ line-height: 1.5;
915
+ }
916
+
917
+ .ctrl__prepRoom {
918
+ font-family: var(--font-pixel);
919
+ font-size: 10px;
920
+ letter-spacing: 0.16em;
921
+ color: var(--ink-mute);
922
+ }
923
+
924
+ .ctrl__prepIcon { font-size: 42px; color: var(--purple-night); }
925
+
926
+ .ctrl__prepBtn {
927
+ margin-top: 6px;
928
+ font-family: var(--font-pixel);
929
+ font-size: 12px;
930
+ padding: 14px 22px;
931
+ background: var(--yellow);
932
+ border: 3px solid var(--ink);
933
+ box-shadow: 4px 4px 0 var(--ink);
934
+ color: var(--ink);
935
+ }
936
+
937
+ .ctrl__rotateIcon {
938
+ font-size: 64px;
939
+ animation: rotateHint 1.4s ease-in-out infinite;
940
+ }
941
+ @keyframes rotateHint {
942
+ 0%, 100% { transform: rotate(-15deg); }
943
+ 50% { transform: rotate(75deg); }
944
+ }
945
+ .ctrl__rotateMsg { font-family: var(--font-pixel); font-size: 14px; }
946
+ .ctrl__rotateSub { font-family: var(--font-mono); font-size: 13px; opacity: 0.7; }
947
+
948
+ .ctrl--play { padding: 8px; gap: 8px; }
949
+
950
+ .ctrl__topBar {
951
+ display: grid;
952
+ grid-template-columns: 1fr auto 1fr;
953
+ align-items: center;
954
+ gap: 8px;
955
+ padding: 4px 6px;
956
+ }
957
+
958
+ .ctrl__pill {
959
+ justify-self: start;
960
+ font-family: var(--font-pixel);
961
+ font-size: 9px;
962
+ padding: 6px 8px;
963
+ background: rgba(255, 247, 241, 0.1);
964
+ border: 2px solid var(--cream);
965
+ color: var(--cream);
966
+ }
967
+ .ctrl__pill.is-on { background: var(--green); color: var(--ink); border-color: var(--ink); }
968
+
969
+ .ctrl__room {
970
+ justify-self: end;
971
+ font-family: var(--font-pixel);
972
+ font-size: 9px;
973
+ letter-spacing: 0.16em;
974
+ color: var(--yellow);
975
+ }
976
+
977
+ .ctrl__scoreBox {
978
+ justify-self: center;
979
+ display: flex;
980
+ align-items: baseline;
981
+ gap: 8px;
982
+ padding: 4px 10px;
983
+ background: rgba(255, 247, 241, 0.08);
984
+ border: 2px solid rgba(255, 247, 241, 0.25);
985
+ }
986
+ .ctrl__scoreLabel { font-family: var(--font-pixel); font-size: 9px; color: var(--cream); opacity: 0.7; }
987
+ .ctrl__scoreNum { font-family: var(--font-pixel); font-size: 18px; color: var(--yellow); }
988
+
989
+ .ctrl__playArea {
990
+ flex: 1;
991
+ display: grid;
992
+ grid-template-columns: 28px 1fr;
993
+ gap: 10px;
994
+ min-height: 0;
995
+ align-items: stretch;
996
+ }
997
+
998
+ .ctrl__timeTube {
999
+ background: var(--ink);
1000
+ border: 3px solid var(--cream);
1001
+ padding: 3px;
1002
+ display: flex;
1003
+ align-items: flex-end;
1004
+ height: 100%;
1005
+ position: relative;
1006
+ }
1007
+ .ctrl__timeFill {
1008
+ width: 100%;
1009
+ background: #5db7e8;
1010
+ transition: height 200ms linear, background-color 240ms;
1011
+ }
1012
+ .ctrl__timeTube::before {
1013
+ content: '';
1014
+ position: absolute;
1015
+ inset: 25% 0 auto 0;
1016
+ height: 1px;
1017
+ background: rgba(255, 247, 241, 0.25);
1018
+ box-shadow: 0 16px 0 rgba(255, 247, 241, 0.25), 0 32px 0 rgba(255, 247, 241, 0.25);
1019
+ }
1020
+
1021
+ .ctrl__pad {
1022
+ display: grid;
1023
+ grid-template-columns: 1fr 1fr;
1024
+ gap: 10px;
1025
+ min-height: 0;
1026
+ height: 100%;
1027
+ }
1028
+
1029
+ .ctrl__btn {
1030
+ font-family: var(--font-pixel);
1031
+ color: var(--ink);
1032
+ background: var(--yellow);
1033
+ border: 3px solid var(--ink);
1034
+ box-shadow: 4px 4px 0 #000;
1035
+ touch-action: manipulation;
1036
+ display: flex;
1037
+ align-items: center;
1038
+ justify-content: center;
1039
+ height: 100%;
1040
+ font-size: clamp(32px, 9vh, 72px);
1041
+ }
1042
+ .ctrl__btn--left { background: var(--blue); }
1043
+ .ctrl__btn--right { background: var(--coral); }
1044
+ .ctrl__btn:active {
1045
+ transform: translate(3px, 3px);
1046
+ box-shadow: 2px 2px 0 #000;
1047
+ background: var(--green);
1048
+ }
1049
+
1050
+ @media (orientation: portrait) {
1051
+ .ctrl__prep { max-width: 86vw; }
1052
+ }
1053
+
1054
+ /* ============================================
1055
+ GAME POPUP (QR + start) — overlays canvas
1056
+ ============================================ */
1057
+
1058
+ .game__popup {
1059
+ position: absolute;
1060
+ inset: 0;
1061
+ display: flex;
1062
+ align-items: center;
1063
+ justify-content: center;
1064
+ background: rgba(26, 21, 48, 0.62);
1065
+ padding: 14px;
1066
+ z-index: 5;
1067
+ }
1068
+
1069
+ .game__popupInner {
1070
+ background: var(--cream);
1071
+ border: 4px solid var(--ink);
1072
+ box-shadow: 10px 10px 0 var(--ink);
1073
+ padding: 20px 22px;
1074
+ display: flex;
1075
+ flex-direction: column;
1076
+ gap: 14px;
1077
+ max-width: min(560px, calc(100% - 24px));
1078
+ max-height: calc(100% - 24px);
1079
+ overflow: auto;
1080
+ }
1081
+
1082
+ .game__popupHead { display: flex; align-items: center; gap: 8px; }
1083
+
1084
+ .game__popupTitle {
1085
+ font-family: var(--font-pixel);
1086
+ font-size: 11px;
1087
+ letter-spacing: 0.14em;
1088
+ color: var(--ink);
1089
+ }
1090
+
1091
+ .game__popupBody {
1092
+ display: grid;
1093
+ grid-template-columns: 168px 1fr;
1094
+ gap: 16px;
1095
+ align-items: stretch;
1096
+ }
1097
+
1098
+ .game__popupQrBox {
1099
+ background: var(--cream-2);
1100
+ border: 3px solid var(--ink);
1101
+ box-shadow: 4px 4px 0 var(--ink);
1102
+ padding: 8px;
1103
+ display: flex;
1104
+ align-items: center;
1105
+ justify-content: center;
1106
+ aspect-ratio: 1;
1107
+ }
1108
+ .game__popupQrBox img { width: 100%; height: 100%; image-rendering: pixelated; }
1109
+
1110
+ .game__popupCopy {
1111
+ display: flex;
1112
+ flex-direction: column;
1113
+ gap: 6px;
1114
+ font-family: var(--font-mono);
1115
+ font-size: 13px;
1116
+ color: var(--ink-soft);
1117
+ }
1118
+
1119
+ .game__popupLine { display: flex; gap: 6px; }
1120
+ .game__popupLine--ok {
1121
+ font-family: var(--font-pixel);
1122
+ font-size: 10px;
1123
+ color: var(--ink);
1124
+ background: var(--green);
1125
+ padding: 6px 8px;
1126
+ border: 2px solid var(--ink);
1127
+ align-self: flex-start;
1128
+ letter-spacing: 0.06em;
1129
+ }
1130
+
1131
+ .game__popupRoom {
1132
+ margin-top: 4px;
1133
+ font-family: var(--font-pixel);
1134
+ font-size: 10px;
1135
+ letter-spacing: 0.12em;
1136
+ color: var(--ink);
1137
+ }
1138
+ .game__popupRoom strong { color: var(--purple-night); }
1139
+
1140
+ .game__popupUrl {
1141
+ background: var(--ink);
1142
+ color: var(--cream);
1143
+ padding: 6px 8px;
1144
+ border: 2px solid var(--ink);
1145
+ font-family: var(--font-mono);
1146
+ font-size: 11px;
1147
+ word-break: break-all;
1148
+ }
1149
+
1150
+ .game__popupFoot {
1151
+ display: flex;
1152
+ align-items: center;
1153
+ justify-content: space-between;
1154
+ gap: 12px;
1155
+ border-top: 2px dashed var(--ink-soft);
1156
+ padding-top: 12px;
1157
+ flex-wrap: wrap;
1158
+ }
1159
+
1160
+ .game__popupHint {
1161
+ font-family: var(--font-pixel);
1162
+ font-size: 9px;
1163
+ color: var(--ink-mute);
1164
+ letter-spacing: 0.12em;
1165
+ }
1166
+
1167
+ @media (max-width: 720px) {
1168
+ .game__popupBody { grid-template-columns: 1fr; }
1169
+ .game__popupQrBox { max-width: 220px; margin: 0 auto; }
1170
+ }
1171
+
1172
+ /* ============================================
1173
+ CONTROLLER — start screen
1174
+ ============================================ */
1175
+
1176
+ .ctrl--start {
1177
+ align-items: center;
1178
+ justify-content: center;
1179
+ }
1180
+
1181
+ .ctrl__startPanel {
1182
+ background: var(--cream);
1183
+ color: var(--ink);
1184
+ border: 4px solid var(--ink);
1185
+ box-shadow: 6px 6px 0 #000;
1186
+ padding: 22px 28px;
1187
+ text-align: center;
1188
+ max-width: 440px;
1189
+ display: flex;
1190
+ flex-direction: column;
1191
+ gap: 10px;
1192
+ align-items: center;
1193
+ }
1194
+
1195
+ .ctrl__startEyebrow {
1196
+ font-family: var(--font-pixel);
1197
+ font-size: 10px;
1198
+ letter-spacing: 0.16em;
1199
+ color: var(--ink-mute);
1200
+ }
1201
+
1202
+ .ctrl__startTitle {
1203
+ font-family: var(--font-pixel);
1204
+ font-size: 18px;
1205
+ letter-spacing: 0.06em;
1206
+ }
1207
+
1208
+ .ctrl__startSub {
1209
+ font-family: var(--font-mono);
1210
+ font-size: 14px;
1211
+ color: var(--ink-soft);
1212
+ line-height: 1.5;
1213
+ }
1214
+
1215
+ .ctrl__startBtn {
1216
+ margin-top: 6px;
1217
+ font-family: var(--font-pixel);
1218
+ font-size: 16px;
1219
+ letter-spacing: 0.1em;
1220
+ padding: 16px 36px;
1221
+ background: var(--yellow);
1222
+ border: 4px solid var(--ink);
1223
+ box-shadow: 6px 6px 0 var(--ink);
1224
+ color: var(--ink);
1225
+ transition: transform 90ms, box-shadow 90ms;
1226
+ }
1227
+ .ctrl__startBtn:hover { transform: translate(-1px, -1px); box-shadow: 7px 7px 0 var(--ink); }
1228
+ .ctrl__startBtn:active { transform: translate(6px, 6px); box-shadow: 0 0 0 var(--ink); }
1229
+ .ctrl__startBtn:disabled {
1230
+ background: var(--cream-2);
1231
+ color: var(--ink-mute);
1232
+ cursor: wait;
1233
+ box-shadow: 3px 3px 0 var(--ink);
1234
+ }
1235
+
1236
+ .ctrl__startLast {
1237
+ font-family: var(--font-pixel);
1238
+ font-size: 10px;
1239
+ letter-spacing: 0.12em;
1240
+ color: var(--ink-mute);
1241
+ }
1242
+
vite.config.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ host: true,
8
+ port: 5173,
9
+ open: false,
10
+ proxy: {
11
+ '/api': {
12
+ target: 'http://localhost:5174',
13
+ changeOrigin: true,
14
+ },
15
+ },
16
+ },
17
+ });