OrbitMC commited on
Commit
ccf71d5
·
verified ·
1 Parent(s): b26aa45

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +427 -680
app.py CHANGED
@@ -1,23 +1,21 @@
1
  import json
2
  import asyncio
3
  import os
 
4
  from typing import Dict, List
5
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
6
  from fastapi.responses import HTMLResponse
7
 
8
  app = FastAPI()
9
 
10
- # --- SERVER STATE & CONFIG ---
11
- DATA_FILE = "/data/leaderboard.json" # Use /data for persistence in some Docker envs, or local
12
- if not os.path.exists("/data"):
13
- DATA_FILE = "leaderboard.json"
14
-
15
- # Fixed seed so everyone sees the same level
16
- LEVEL_SEED = 12345
17
 
18
  class ConnectionManager:
19
  def __init__(self):
20
- # active_connections: {socket: player_data}
21
  self.active_connections: Dict[WebSocket, dict] = {}
22
  self.leaderboard: List[dict] = self.load_leaderboard()
23
 
@@ -31,26 +29,27 @@ class ConnectionManager:
31
  return []
32
 
33
  def save_leaderboard(self):
34
- # Sort by score desc, keep top 3
35
  self.leaderboard.sort(key=lambda x: x['score'], reverse=True)
36
- self.leaderboard = self.leaderboard[:3]
37
  try:
38
  with open(DATA_FILE, "w") as f:
39
  json.dump(self.leaderboard, f)
40
  except Exception as e:
41
- print(f"Error saving leaderboard: {e}")
42
 
43
  async def connect(self, websocket: WebSocket):
44
  await websocket.accept()
45
- # Initialize with placeholder
46
  self.active_connections[websocket] = {
47
- "username": "Anonymous",
48
- "x": 0, "y": 0, "vx": 0, "vy": 0,
49
- "score": 0,
 
50
  "faceRight": True,
51
- "dead": False
 
52
  }
53
- # Send initial config to the new client
54
  await websocket.send_json({
55
  "type": "init",
56
  "seed": LEVEL_SEED,
@@ -59,13 +58,12 @@ class ConnectionManager:
59
 
60
  def disconnect(self, websocket: WebSocket):
61
  if websocket in self.active_connections:
62
- # Check if their final score qualifies for leaderboard
63
- player_data = self.active_connections[websocket]
64
- self.update_leaderboard(player_data['username'], player_data['score'])
65
  del self.active_connections[websocket]
66
 
67
  def update_leaderboard(self, username, score):
68
- # Check if user already in leaderboard, update if score is higher
69
  found = False
70
  for entry in self.leaderboard:
71
  if entry['username'] == username:
@@ -73,74 +71,80 @@ class ConnectionManager:
73
  entry['score'] = score
74
  found = True
75
  break
76
-
77
  if not found:
78
  self.leaderboard.append({"username": username, "score": score})
79
-
80
  self.save_leaderboard()
81
 
82
  async def handle_message(self, websocket: WebSocket, data: dict):
 
 
 
 
83
  if data['type'] == 'update':
84
- # Update server state for this player
85
- if websocket in self.active_connections:
86
- p = self.active_connections[websocket]
87
- p['username'] = data.get('username', p['username'])
88
- p['x'] = data.get('x', 0)
89
- p['y'] = data.get('y', 0)
90
- p['vx'] = data.get('vx', 0)
91
- p['vy'] = data.get('vy', 0)
92
- p['faceRight'] = data.get('faceRight', True)
93
- p['dead'] = data.get('dead', False)
94
-
95
- # Only update score if not dead (prevent hacking usually, but simple here)
96
- if not p['dead']:
97
- p['score'] = max(p['score'], data.get('score', 0))
98
 
99
  elif data['type'] == 'gameover':
100
- if websocket in self.active_connections:
101
- p = self.active_connections[websocket]
102
- p['dead'] = True
103
- self.update_leaderboard(p['username'], data.get('score', 0))
104
 
105
  async def broadcast_gamestate(self):
106
- """Sends the state of all players to all players"""
107
- if not self.active_connections:
108
- return
109
 
110
- # Compile list of other players
111
  players_list = []
112
  for ws, p in self.active_connections.items():
113
  if not p['dead']:
114
- players_list.append(p)
115
-
116
- message = {
117
- "type": "gamestate",
118
- "players": players_list,
119
- "leaderboard": self.leaderboard
 
 
 
 
 
 
 
 
 
120
  }
121
 
122
  # Broadcast
123
- disconnected = []
124
- for connection in self.active_connections:
125
  try:
126
- await connection.send_json(message)
127
  except:
128
- disconnected.append(connection)
129
 
130
- for d in disconnected:
131
- self.disconnect(d)
132
 
133
  manager = ConnectionManager()
134
 
135
- # Background task to push updates at 20 FPS
136
  @app.on_event("startup")
137
- async def start_broadcast_loop():
138
  asyncio.create_task(broadcast_loop())
139
 
140
  async def broadcast_loop():
 
141
  while True:
 
142
  await manager.broadcast_gamestate()
143
- await asyncio.sleep(0.05) # 20 updates per second
 
 
144
 
145
  @app.websocket("/ws")
146
  async def websocket_endpoint(websocket: WebSocket):
@@ -151,12 +155,10 @@ async def websocket_endpoint(websocket: WebSocket):
151
  await manager.handle_message(websocket, data)
152
  except WebSocketDisconnect:
153
  manager.disconnect(websocket)
154
- except Exception as e:
155
- print(f"Error: {e}")
156
  manager.disconnect(websocket)
157
 
158
- # --- FRONTEND HTML ---
159
-
160
  @app.get("/", response_class=HTMLResponse)
161
  async def get():
162
  return """
@@ -165,23 +167,23 @@ async def get():
165
  <head>
166
  <meta charset="UTF-8">
167
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
168
- <title>Jungle Jump Multiplayer</title>
169
  <style>
170
- body { margin: 0; padding: 0; background-color: #222; display: flex; justify-content: center; align-items: center; height: 100vh; overflow: hidden; font-family: 'Comic Sans MS', 'Chalkboard SE', sans-serif; touch-action: none; user-select: none; -webkit-user-select: none; }
171
- #gameContainer { position: relative; width: 100%; height: 100%; max-width: 50vh; aspect-ratio: 9/16; background: #659d33; box-shadow: 0 0 20px rgba(0,0,0,0.5); overflow: hidden; }
172
  canvas { display: block; width: 100%; height: 100%; }
173
  #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
174
- #score { position: absolute; top: 20px; right: 20px; font-size: 28px; font-weight: bold; color: #000; text-shadow: 1px 1px 0px rgba(255,255,255,0.5); z-index: 5;}
175
- #leaderboard { position: absolute; top: 20px; left: 20px; font-size: 14px; color: #fff; background: rgba(0,0,0,0.5); padding: 10px; border-radius: 8px; z-index: 5; text-align: left; }
176
- #leaderboard h3 { margin: 0 0 5px 0; color: #d8c222; font-size: 16px; }
177
- #gameOverScreen, #loginScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); display: flex; flex-direction: column; justify-content: center; align-items: center; color: white; z-index: 10; }
178
- #gameOverScreen { display: none; }
179
- h1 { font-size: 40px; margin: 0 0 10px 0; text-shadow: 2px 2px #000; color: #d8c222; }
180
- button { padding: 15px 40px; font-size: 24px; background: #d8c222; border: 4px solid #000; border-radius: 15px; cursor: pointer; font-family: inherit; font-weight: bold; pointer-events: auto; color: #000; transition: transform 0.1s; margin-top: 20px; }
181
  button:active { transform: scale(0.95); }
182
- input { padding: 10px; font-size: 20px; border-radius: 8px; border: 2px solid #5c6b1f; text-align: center; margin-bottom: 10px; font-family: inherit; }
183
- .tutorial-text { position: absolute; bottom: 120px; width: 100%; text-align: center; color: rgba(0,0,0,0.4); font-size: 14px; font-weight: bold; transition: opacity 1s ease-out; }
184
- #connectionStatus { position: absolute; bottom: 5px; right: 5px; color: rgba(255,255,255,0.5); font-size: 10px; }
185
  </style>
186
  </head>
187
  <body>
@@ -191,730 +193,475 @@ async def get():
191
 
192
  <div id="ui-layer">
193
  <div id="score">0</div>
194
- <div id="leaderboard">
195
- <h3>Top 3</h3>
196
- <div id="lb-content">Loading...</div>
197
- </div>
198
- <div class="tutorial-text">Tap Sides to Move • Tap Center to Shoot</div>
199
- <div id="connectionStatus">Disconnected</div>
200
  </div>
201
 
202
- <!-- Login Screen -->
203
- <div id="loginScreen">
204
- <h1>JUNGLE JUMP</h1>
205
- <p style="margin-bottom: 20px">Multiplayer Edition</p>
206
- <input type="text" id="usernameInput" placeholder="Enter Username" maxlength="10">
207
- <button id="startBtn">START GAME</button>
208
  </div>
209
 
210
- <div id="gameOverScreen">
211
- <h1>GAME OVER</h1>
212
- <p id="finalScore" style="font-size: 24px; margin-bottom: 30px;">Score: 0</p>
213
- <button id="restartBtn">Play Again</button>
 
214
  </div>
215
  </div>
216
 
217
  <script>
218
- /**
219
- * JUNGLE JUMP - MULTIPLAYER
220
- */
221
-
222
- // --- UTILS: COOKIES & RNG ---
223
-
224
- function setCookie(cname, cvalue, exdays) {
225
- const d = new Date();
226
- d.setTime(d.getTime() + (exdays*24*60*60*1000));
227
- let expires = "expires="+ d.toUTCString();
228
- document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
229
  }
230
-
231
- function getCookie(cname) {
232
- let name = cname + "=";
233
- let decodedCookie = decodeURIComponent(document.cookie);
234
- let ca = decodedCookie.split(';');
235
- for(let i = 0; i <ca.length; i++) {
236
  let c = ca[i];
237
- while (c.charAt(0) == ' ') c = c.substring(1);
238
- if (c.indexOf(name) == 0) return c.substring(name.length, c.length);
239
  }
240
- return "";
241
  }
242
 
243
- // Linear Congruential Generator for synced levels
244
  class SeededRNG {
245
- constructor(seed) {
246
- this.seed = seed;
247
- }
248
  next() {
249
  this.seed = (this.seed * 9301 + 49297) % 233280;
250
  return this.seed / 233280;
251
  }
252
  }
253
- let rng = new SeededRNG(12345); // Will be updated by server
254
 
255
- // --- GAME SETUP ---
 
 
 
 
 
256
 
 
257
  const canvas = document.getElementById('gameCanvas');
258
  const ctx = canvas.getContext('2d', { alpha: false });
259
- const scoreEl = document.getElementById('score');
260
- const lbContent = document.getElementById('lb-content');
261
- const gameOverScreen = document.getElementById('gameOverScreen');
262
  const loginScreen = document.getElementById('loginScreen');
263
- const usernameInput = document.getElementById('usernameInput');
264
- const finalScoreEl = document.getElementById('finalScore');
265
- const restartBtn = document.getElementById('restartBtn');
266
- const startBtn = document.getElementById('startBtn');
267
- const tutorialText = document.querySelector('.tutorial-text');
268
- const connStatus = document.getElementById('connectionStatus');
269
 
270
- const GAME_W = 375;
271
- const GAME_H = 812;
 
 
 
 
272
 
273
- // Physics
274
- const GRAVITY = 0.4;
275
- const JUMP_FORCE = -11.5;
276
- const SPRING_FORCE = -22;
277
- const MOVEMENT_SPEED = 6.5;
278
 
279
- // State
280
- let gameState = 'LOGIN'; // LOGIN, PLAYING, GAMEOVER
281
- let score = 0;
282
- let cameraY = 0;
283
- let username = "Player";
284
- let globalSeed = 12345;
285
 
286
- // Networking
 
287
  let socket;
288
- let otherPlayers = []; // {username, x, y, vx, vy, faceRight}
289
- let lastUpdateSent = 0;
290
 
291
- // Entities
292
- let platforms = [];
293
- let monsters = [];
294
- let bullets = [];
295
- let powerups = [];
296
- let particles = [];
297
-
298
- const player = {
299
- x: GAME_W / 2 - 20,
300
- y: GAME_H - 200,
301
- w: 40,
302
- h: 40,
303
- vx: 0,
304
- vy: 0,
305
- faceRight: true,
306
- hasPropeller: false,
307
- propellerTimer: 0
308
- };
309
-
310
- const keys = { left: false, right: false, shoot: false };
311
-
312
- // --- NETWORKING ---
313
-
314
- function initNetwork() {
315
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
316
- socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
317
 
318
- socket.onopen = () => {
319
- connStatus.innerText = "Connected";
320
- connStatus.style.color = "#0f0";
321
- };
322
 
323
- socket.onclose = () => {
324
- connStatus.innerText = "Disconnected";
325
- connStatus.style.color = "#f00";
 
 
 
326
  };
327
 
328
- socket.onmessage = (event) => {
329
- const data = JSON.parse(event.data);
330
 
331
  if (data.type === 'init') {
332
- globalSeed = data.seed;
333
- updateLeaderboardUI(data.leaderboard);
334
- }
335
- else if (data.type === 'gamestate') {
336
- // Filter out self
337
- otherPlayers = data.players.filter(p => p.username !== username);
338
- updateLeaderboardUI(data.leaderboard);
 
 
339
  }
340
  };
 
 
 
 
 
341
  }
342
 
343
- function sendUpdate() {
344
- if (socket && socket.readyState === WebSocket.OPEN) {
345
- const now = Date.now();
346
- if (now - lastUpdateSent > 50) { // Throttle client updates (20fps limit)
347
- socket.send(JSON.stringify({
348
- type: 'update',
349
- username: username,
350
- x: Math.round(player.x),
351
- y: Math.round(player.y), // Send absolute Y
352
- vx: player.vx,
353
- vy: player.vy,
354
- faceRight: player.faceRight,
355
- score: score,
356
- dead: gameState === 'GAMEOVER'
357
- }));
358
- lastUpdateSent = now;
359
  }
 
 
 
 
 
 
 
 
 
 
 
 
360
  }
361
  }
362
 
363
- function sendGameOver() {
364
- if (socket && socket.readyState === WebSocket.OPEN) {
365
  socket.send(JSON.stringify({
366
- type: 'gameover',
367
- score: score
 
 
 
 
 
 
368
  }));
369
  }
370
  }
371
 
372
- function updateLeaderboardUI(lbData) {
373
- if (!lbData || lbData.length === 0) {
374
- lbContent.innerHTML = "None yet";
375
- return;
376
- }
377
- let html = "";
378
- lbData.forEach((entry, idx) => {
379
- html += `<div>${idx+1}. ${entry.username}: ${entry.score}</div>`;
380
- });
381
- lbContent.innerHTML = html;
382
  }
383
 
384
- // --- DRAWING FUNCTIONS ---
385
-
386
- function drawDoodler(x, y, w, h, facingRight, isSelf = true, name = "") {
387
- ctx.save();
388
-
389
- // Camera adjustment for self is automatic, but for others we must adjust relative to cameraY
390
- let drawY = y;
391
- if (!isSelf) {
392
- drawY = y + cameraY;
393
-
394
- // Optim: Don't draw if off screen
395
- if (drawY < -50 || drawY > GAME_H + 50) {
396
- ctx.restore();
397
- return;
398
- }
399
- ctx.globalAlpha = 0.5; // Ghost effect for others
400
- }
401
-
402
- ctx.translate(x + w/2, drawY + h/2);
403
-
404
- let tilt = isSelf ? player.vx * 0.05 : 0;
405
- ctx.rotate(tilt);
406
-
407
- if (!facingRight) ctx.scale(-1, 1);
408
-
409
- // Legs
410
- ctx.strokeStyle = '#000';
411
- ctx.lineWidth = 3;
412
- ctx.lineCap = 'round';
413
-
414
- ctx.beginPath();
415
- ctx.moveTo(-10, 15); ctx.lineTo(-12, 22);
416
- ctx.moveTo(0, 15); ctx.lineTo(0, 22);
417
- ctx.moveTo(10, 15); ctx.lineTo(12, 22);
418
- ctx.stroke();
419
-
420
- // Body
421
- ctx.fillStyle = isSelf ? '#d8c222' : '#88d8b0';
422
- ctx.beginPath();
423
- ctx.moveTo(-15, -10);
424
- ctx.lineTo(-15, 15);
425
- ctx.quadraticCurveTo(0, 20, 15, 15);
426
- ctx.lineTo(15, -10);
427
- ctx.quadraticCurveTo(0, -20, -15, -10);
428
- ctx.fill();
429
- ctx.stroke();
430
-
431
- // Snout
432
- ctx.beginPath();
433
- ctx.rect(12, -8, 12, 12);
434
- ctx.fill();
435
- ctx.stroke();
436
-
437
- // Eye
438
- ctx.fillStyle = '#fff';
439
- ctx.beginPath();
440
- ctx.arc(8, -5, 6, 0, Math.PI * 2);
441
- ctx.fill();
442
- ctx.stroke();
443
-
444
- ctx.fillStyle = '#000';
445
- ctx.beginPath();
446
- ctx.arc(10, -5, 2, 0, Math.PI * 2);
447
- ctx.fill();
448
 
449
- // Backpack
450
- ctx.strokeStyle = '#5c6b1f';
451
- ctx.lineWidth = 2;
452
- ctx.beginPath();
453
- ctx.moveTo(-14, 5); ctx.lineTo(14, 5);
454
- ctx.moveTo(-14, 10); ctx.lineTo(14, 10);
455
- ctx.stroke();
456
-
457
- // Propeller (Simplified for others)
458
- if (isSelf && player.hasPropeller) {
459
- ctx.strokeStyle = '#000';
460
- ctx.fillStyle = 'orange';
461
- ctx.beginPath(); ctx.arc(0, -18, 10, Math.PI, 0); ctx.fill(); ctx.stroke();
462
- ctx.beginPath(); ctx.moveTo(0, -18); ctx.lineTo(0, -28); ctx.stroke();
463
  }
 
464
 
465
- ctx.restore();
466
-
467
- // Draw Name Tag
468
- if (!isSelf && name) {
469
- ctx.save();
470
- ctx.fillStyle = "white";
471
- ctx.font = "10px sans-serif";
472
- ctx.textAlign = "center";
473
- ctx.fillText(name, x + w/2, drawY - 15);
474
- ctx.restore();
475
- }
476
  }
477
 
478
- function drawPlatform(p) {
479
- // Check bounds optimization
480
- let drawY = p.y;
481
- // In this game logic, p.y is relative to the world top (0), not screen.
482
- // Wait, in the original logic `p.y` was screen coordinate initially, then adjusted.
483
- // To support multiplayer, p.y must be ABSOLUTE WORLD COORDINATE.
484
- // However, keeping the original logic: p.y stays in screen space, and we perform a "shift" on everything when player goes up.
485
- // Actually, to sync with server, it's easier to keep the visual illusion logic:
486
- // p.y is stored locally relative to the 'current view' bottom.
487
- // Since we are syncing the SEED, every client generates the platform at the same logic interval.
 
 
488
 
489
- // Actually, simplest approach for this port:
490
- // Keep local coordinates. Since everyone runs the same RNG seed,
491
- // "Platform at height 5000" exists on everyone's client at height 5000 relative to game start.
492
- // The visual p.y changes as we scroll.
493
 
494
- if (p.y > GAME_H) return;
 
 
495
 
496
- let color = '#76c442';
497
- let detailColor = '#9fe060';
 
 
 
 
498
 
499
- if (p.vx !== 0) {
500
- color = '#4287c4';
501
- detailColor = '#60a9e0';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  }
503
-
504
- ctx.save();
505
- ctx.fillStyle = color;
506
- ctx.strokeStyle = '#000';
507
- ctx.lineWidth = 2;
508
-
509
- ctx.beginPath();
510
- ctx.roundRect(p.x, p.y, p.w, p.h, 6);
511
- ctx.fill();
512
- ctx.stroke();
513
-
514
- ctx.fillStyle = detailColor;
515
- ctx.beginPath();
516
- ctx.roundRect(p.x + 3, p.y + 2, p.w - 6, p.h/2 - 2, 3);
517
- ctx.fill();
518
 
519
- ctx.restore();
 
520
  }
521
 
522
- // ... (Monster and foliage drawing reused but simplified for brevity) ...
523
- function drawBackground() {
524
- ctx.fillStyle = '#659d33';
525
- ctx.fillRect(0, 0, GAME_W, GAME_H);
526
- ctx.strokeStyle = 'rgba(0, 30, 0, 0.05)';
527
- ctx.lineWidth = 2;
528
- const gridSize = 40;
529
- const offset = Math.floor(cameraY) % gridSize;
530
- ctx.beginPath();
531
- for(let x = 0; x <= GAME_W; x += gridSize) { ctx.moveTo(x, 0); ctx.lineTo(x, GAME_H); }
532
- for(let y = offset; y <= GAME_H; y += gridSize) { ctx.moveTo(0, y); ctx.lineTo(GAME_W, y); }
533
- ctx.stroke();
534
- // (Foliage simplified: just simple circles for perf)
535
- const spacing = 100;
536
- const startIdx = Math.floor(cameraY / spacing);
537
- const endIdx = startIdx + Math.ceil(GAME_H / spacing) + 1;
538
- for(let i = startIdx; i < endIdx; i++) {
539
- let yPos = GAME_H - (i * spacing - cameraY);
540
- let xOff = (Math.sin(i) * 20);
541
- ctx.fillStyle = "rgba(0,0,0,0.1)";
542
- ctx.beginPath(); ctx.arc(20 + xOff, yPos, 20, 0, Math.PI*2); ctx.fill();
543
- ctx.beginPath(); ctx.arc(GAME_W - 20 - xOff, yPos, 20, 0, Math.PI*2); ctx.fill();
544
- }
545
- }
546
-
547
- // --- LOGIC ---
548
 
549
- function startGame() {
550
- username = usernameInput.value.trim() || "Player";
551
- setCookie("username", username, 30);
552
- loginScreen.style.display = 'none';
553
- initGame();
554
- }
555
 
556
- function initGame() {
557
- platforms = [];
558
- monsters = [];
559
- bullets = [];
560
- powerups = [];
561
- particles = [];
562
- score = 0;
563
- cameraY = 0;
564
 
565
- // Reset Player
566
- player.x = GAME_W / 2 - 20;
567
- player.y = GAME_H - 250;
568
- player.vx = 0;
569
- player.vy = 0;
570
- player.hasPropeller = false;
571
- player.faceRight = true;
572
 
573
- // Initialize RNG with Global Seed
574
- rng = new SeededRNG(globalSeed);
575
 
576
- // Generate initial platforms
577
- // Start Platform
578
- platforms.push({x: GAME_W/2 - 30, y: GAME_H - 150, w: 60, h: 15, vx: 0, broken: false});
 
 
579
 
580
- let y = GAME_H - 250;
581
- while(y > -200) {
582
- generatePlatformAt(y);
583
- y -= 80 + rng.next() * 40;
 
 
 
 
 
 
 
584
  }
585
-
586
- gameState = 'PLAYING';
587
- gameOverScreen.style.display = 'none';
588
- scoreEl.innerText = '0';
589
-
590
- tutorialText.style.opacity = '1';
591
- setTimeout(() => { tutorialText.style.opacity = '0'; }, 3000);
592
-
593
- requestAnimationFrame(gameLoop);
594
- }
595
 
596
- function generatePlatformAt(y) {
597
- let vx = 0;
598
-
599
- // Difficulty curve based on absolute height roughly
600
- // Since cameraY resets visually, we can't use score directly for generation easily
601
- // unless we track total generated height.
602
- // For simplicity: Just use random probabilities provided by seeded RNG.
603
-
604
- let moveChance = 0.2;
605
- if (rng.next() < moveChance) {
606
- vx = (rng.next() > 0.5 ? 2 : -2);
607
  }
608
 
609
- const p = {
610
- x: rng.next() * (GAME_W - 60),
611
- y: y,
612
- w: 60,
613
- h: 15,
614
- vx: vx,
615
- broken: false
616
- };
617
- platforms.push(p);
618
-
619
- if (rng.next() < 0.05) {
620
- monsters.push({
621
- x: rng.next() * (GAME_W - 60),
622
- y: y - 60,
623
- w: 50,
624
- h: 50,
625
- vx: rng.next() < 0.5 ? 1 : -1
626
- });
627
- }
628
- else if (rng.next() < 0.08) {
629
- powerups.push({ x: p.x + 15, y: p.y, type: 'spring', active: false });
630
- }
631
- else if (rng.next() < 0.02) {
632
- powerups.push({ x: p.x + 15, y: p.y - 20, type: 'propeller' });
633
  }
634
  }
635
 
636
- function generateLevel() {
637
- let highestY = GAME_H;
638
- platforms.forEach(p => { if(p.y < highestY) highestY = p.y; });
639
- // Generate ahead
640
- if (highestY > -100) {
641
- generatePlatformAt(highestY - (80 + rng.next() * 50));
642
- }
643
- }
644
-
645
- function shoot() {
646
- if (gameState !== 'PLAYING') return;
647
- bullets.push({ x: player.x + player.w / 2, y: player.y, vy: -15 });
648
  }
649
 
650
- function update() {
651
- if (gameState !== 'PLAYING') return;
652
-
653
- // Movement
654
- if (keys.left) player.vx = -MOVEMENT_SPEED;
655
- else if (keys.right) player.vx = MOVEMENT_SPEED;
656
- else player.vx *= 0.8;
657
-
658
- player.x += player.vx;
659
 
660
- if (player.x + player.w < 0) player.x = GAME_W;
661
- if (player.x > GAME_W) player.x = -player.w;
662
-
663
- if (player.vx > 0.5) player.faceRight = true;
664
- if (player.vx < -0.5) player.faceRight = false;
665
-
666
- // Gravity
667
- if (player.hasPropeller) {
668
- player.vy = -8;
669
- player.propellerTimer--;
670
- if (player.propellerTimer <= 0) player.hasPropeller = false;
671
- } else {
672
- player.vy += GRAVITY;
673
  }
674
- player.y += player.vy;
675
 
676
- // Camera / Scrolling
677
- // Important: In Multiplayer, we need to track absolute Y for server syncing,
678
- // but the original game moves objects down.
679
- // To keep it compatible: We send `player.y - cameraY_accumulated`.
680
- // But simplest for this prompt: Just send the visual player.y.
681
- // Since everyone plays same level, relative positions match roughly.
682
 
683
- const threshold = GAME_H * 0.45;
684
- if (player.y < threshold) {
685
- let diff = threshold - player.y;
686
- player.y = threshold;
687
- cameraY += diff;
688
- score += Math.floor(diff / 2);
689
- scoreEl.innerText = score;
690
-
691
- platforms.forEach(p => p.y += diff);
692
- monsters.forEach(m => m.y += diff);
693
- powerups.forEach(p => p.y += diff);
694
- bullets.forEach(b => b.y += diff);
695
- particles.forEach(p => p.y += diff);
696
 
697
- // Adjust other players visually based on scroll
698
- // This is tricky: other players have their OWN cameraY.
699
- // The server receives their VISUAL Y (which is always around 0-800).
700
- // This is a common issue in porting Doodle Jump to MP.
701
- // FIX: The server needs ABSOLUTE Y.
702
- // We will send Absolute Y to server.
703
- // AbsY = (TotalScrolled + VisualY).
704
- // But for this quick prototype, we will just send visual Y and render ghosts relative to screen.
705
- // Note: This means if Player A is 10000ft up, and Player B is 0ft up,
706
- // Player B will see Player A flying above them? No.
707
- // We need to sync coordinates.
708
- // Let's change the packet to send Absolute Height.
709
- // abs_y = score * 2 (roughly) + (GAME_H - player.y).
710
- // For simplicity in this constrained prompt: We just display others if they are within screen bounds relative to YOU.
711
- // Send: `score`.
712
- // Render: If (other.score ~= my.score), draw them.
713
- // This is a heuristic approximation suitable for a game jam style.
714
- }
715
-
716
- // Cleanup
717
- platforms = platforms.filter(p => p.y < GAME_H + 100);
718
- monsters = monsters.filter(m => m.y < GAME_H + 100);
719
- powerups = powerups.filter(p => p.y < GAME_H + 100);
720
- bullets = bullets.filter(b => b.y > -50);
721
-
722
- // Collisions
723
- powerups.forEach(p => {
724
- if (p.type === 'spring') {
725
- if (player.vy > 0 &&
726
- player.x + player.w > p.x - 5 && player.x < p.x + 20 &&
727
- player.y + player.h > p.y && player.y + player.h < p.y + 20) {
728
- player.vy = SPRING_FORCE;
729
- p.active = true;
730
- setTimeout(() => p.active = false, 200);
731
- }
732
- } else if (p.type === 'propeller') {
733
- if (player.x < p.x + 20 && player.x + player.w > p.x &&
734
- player.y < p.y + 20 && player.y + player.h > p.y) {
735
- player.hasPropeller = true;
736
- player.propellerTimer = 300;
737
- p.y = GAME_H + 200;
738
- }
739
  }
740
- });
741
-
742
- if (player.vy > 0 && !player.hasPropeller) {
743
- platforms.forEach(p => {
744
- if (player.x + player.w > p.x + 5 &&
745
- player.x < p.x + p.w - 5 &&
746
- player.y + player.h > p.y &&
747
- player.y + player.h < p.y + p.h + 10) {
748
- player.vy = JUMP_FORCE;
749
- }
750
- });
751
  }
752
 
753
- platforms.forEach(p => {
754
- if (p.vx !== 0) {
755
- p.x += p.vx;
756
- if (p.x < 0 || p.x + p.w > GAME_W) p.vx *= -1;
757
- }
758
- });
759
-
760
- monsters.forEach(m => {
761
- m.x += (m.vx || 0);
762
- if (m.x < 0 || m.x + m.w > GAME_W) m.vx *= -1;
763
-
764
- if (player.x < m.x + m.w && player.x + player.w > m.x &&
765
- player.y < m.y + m.h && player.y + player.h > m.y) {
766
-
767
- if (player.hasPropeller || (player.vy > 0 && player.y + player.h < m.y + m.h/2)) {
768
- m.y = GAME_H + 200;
769
- player.vy = JUMP_FORCE;
770
- } else {
771
- gameOver();
772
- }
773
  }
774
- });
775
-
776
- // Death
777
- if (player.y > GAME_H) {
778
- gameOver();
779
  }
780
 
781
- generateLevel();
782
- sendUpdate();
783
- }
 
 
784
 
785
- function gameOver() {
786
- gameState = 'GAMEOVER';
787
- finalScoreEl.innerText = "Score: " + score;
788
- gameOverScreen.style.display = 'flex';
789
- sendGameOver();
790
  }
791
 
792
- function draw() {
793
- ctx.clearRect(0, 0, canvas.width, canvas.height);
794
- drawBackground();
795
- platforms.forEach(drawPlatform);
796
 
797
- // Draw Powerups
798
- powerups.forEach(p => {
799
- if(p.type === 'spring') {
800
- ctx.fillStyle = '#ccc'; ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
801
- ctx.beginPath(); ctx.rect(p.x, p.y - 5, 15, 5); ctx.fill(); ctx.stroke();
802
- // Simple spring coil
803
- ctx.beginPath(); ctx.moveTo(p.x, p.y-5); ctx.lineTo(p.x+15, p.y-10); ctx.stroke();
804
- } else if(p.type === 'propeller') {
805
- ctx.fillStyle = 'orange'; ctx.beginPath(); ctx.arc(p.x, p.y, 10, 0, Math.PI*2); ctx.fill();
806
- }
807
- });
808
-
809
- // Monsters
810
- monsters.forEach(m => {
811
- ctx.save();
812
- ctx.translate(m.x + m.w/2, m.y + m.h/2);
813
- ctx.fillStyle = '#5c4b8a'; ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
814
- ctx.beginPath(); ctx.ellipse(0, 0, m.w/2, m.h/2 - 5, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
815
- // Eyes
816
- ctx.fillStyle = 'white';
817
- ctx.beginPath(); ctx.arc(-10, -5, 5, 0, Math.PI*2); ctx.fill();
818
- ctx.beginPath(); ctx.arc(10, -5, 5, 0, Math.PI*2); ctx.fill();
819
- ctx.restore();
820
- });
821
-
822
- // Bullets
823
  ctx.fillStyle = 'black';
824
- bullets.forEach(b => {
825
- ctx.beginPath(); ctx.arc(b.x, b.y, 5, 0, Math.PI * 2); ctx.fill();
826
- });
827
-
828
- // Draw Other Players
829
- otherPlayers.forEach(op => {
830
- // Simple visual approximation based on score difference to estimate Y relative to screen
831
- // In a real robust engine we'd use absolute coordinates.
832
- // Here: difference in score ~ pixels.
833
- // If op.score is 1000 and my score is 1000, they are at the same height.
834
- // If op.score is 1200, they are 200px * 2 (roughly) above me.
835
-
836
- let scoreDiff = op.score - score;
837
- let estimatedY = player.y - (scoreDiff * 2); // Heuristic scale factor
838
-
839
- // Interpolate X (Smoothing) - simplified for this code
840
- drawDoodler(op.x, estimatedY, 40, 40, op.faceRight, false, op.username);
841
- });
842
-
843
- if (gameState !== 'GAMEOVER') {
844
- drawDoodler(player.x, player.y, player.w, player.h, player.faceRight);
845
  }
846
  }
847
 
848
- function gameLoop() {
849
- update();
850
  draw();
851
- if (gameState !== 'GAMEOVER') {
852
- requestAnimationFrame(gameLoop);
 
 
 
 
853
  }
854
  }
855
 
856
- function resize() {
 
 
857
  canvas.width = GAME_W;
858
  canvas.height = GAME_H;
859
- }
 
860
 
861
- // Input
862
  window.addEventListener('keydown', e => {
863
- if (e.code === 'ArrowLeft') keys.left = true;
864
- if (e.code === 'ArrowRight') keys.right = true;
865
- if (e.code === 'Space' || e.code === 'ArrowUp') shoot();
866
  });
867
  window.addEventListener('keyup', e => {
868
- if (e.code === 'ArrowLeft') keys.left = false;
869
- if (e.code === 'ArrowRight') keys.right = false;
870
  });
871
 
872
- function handleTouch(x, y) {
873
- if (gameState === 'LOGIN' || gameState === 'GAMEOVER') return;
874
- if (y > GAME_H - 100) return;
875
- const isCentral = x > GAME_W * 0.35 && x < GAME_W * 0.65;
876
- if (isCentral) shoot();
877
- else {
878
- if (x < GAME_W / 2) { keys.left = true; keys.right = false; }
879
- else { keys.right = true; keys.left = false; }
880
- }
881
- }
882
-
883
  canvas.addEventListener('touchstart', e => {
884
  e.preventDefault();
885
- for (let i = 0; i < e.touches.length; i++) {
886
- const t = e.touches[i];
887
- const rect = canvas.getBoundingClientRect();
888
- const scaleX = canvas.width / rect.width;
889
- const scaleY = canvas.height / rect.height;
890
- handleTouch((t.clientX - rect.left) * scaleX, (t.clientY - rect.top) * scaleY);
891
- }
892
- }, {passive: false});
893
-
894
  canvas.addEventListener('touchend', e => {
895
  e.preventDefault();
896
- if (e.touches.length === 0) { keys.left = false; keys.right = false; }
897
  });
898
 
899
- // UI Handlers
900
- restartBtn.addEventListener('click', initGame);
901
- startBtn.addEventListener('click', startGame);
902
-
903
- // Initialization
904
- resize();
905
- window.addEventListener('resize', resize);
906
- initNetwork();
907
 
908
- // Check for cookie
909
- const savedUser = getCookie("username");
910
- if (savedUser) {
911
- usernameInput.value = savedUser;
912
- }
913
 
914
  </script>
915
  </body>
916
  </html>
917
- """
918
 
919
  if __name__ == "__main__":
920
  import uvicorn
 
1
  import json
2
  import asyncio
3
  import os
4
+ import time
5
  from typing import Dict, List
6
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
7
  from fastapi.responses import HTMLResponse
8
 
9
  app = FastAPI()
10
 
11
+ # --- SERVER CONFIG ---
12
+ # Use /data for persistence in Docker, fallback to local
13
+ DATA_FILE = "/data/leaderboard.json" if os.path.exists("/data") else "leaderboard.json"
14
+ LEVEL_SEED = 9999 # Everyone plays the same procedural level
 
 
 
15
 
16
  class ConnectionManager:
17
  def __init__(self):
18
+ # {websocket: {data}}
19
  self.active_connections: Dict[WebSocket, dict] = {}
20
  self.leaderboard: List[dict] = self.load_leaderboard()
21
 
 
29
  return []
30
 
31
  def save_leaderboard(self):
32
+ # Sort desc by score, keep top 5
33
  self.leaderboard.sort(key=lambda x: x['score'], reverse=True)
34
+ self.leaderboard = self.leaderboard[:5]
35
  try:
36
  with open(DATA_FILE, "w") as f:
37
  json.dump(self.leaderboard, f)
38
  except Exception as e:
39
+ print(f"Save error: {e}")
40
 
41
  async def connect(self, websocket: WebSocket):
42
  await websocket.accept()
 
43
  self.active_connections[websocket] = {
44
+ "username": "Loading...",
45
+ "x": 0, "y": 0,
46
+ "vx": 0, "vy": 0,
47
+ "score": 0,
48
  "faceRight": True,
49
+ "dead": False,
50
+ "skin": 0 # Future proofing
51
  }
52
+ # Send Init Packet
53
  await websocket.send_json({
54
  "type": "init",
55
  "seed": LEVEL_SEED,
 
58
 
59
  def disconnect(self, websocket: WebSocket):
60
  if websocket in self.active_connections:
61
+ data = self.active_connections[websocket]
62
+ self.update_leaderboard(data['username'], data['score'])
 
63
  del self.active_connections[websocket]
64
 
65
  def update_leaderboard(self, username, score):
66
+ if score < 10: return # Ignore junk scores
67
  found = False
68
  for entry in self.leaderboard:
69
  if entry['username'] == username:
 
71
  entry['score'] = score
72
  found = True
73
  break
 
74
  if not found:
75
  self.leaderboard.append({"username": username, "score": score})
 
76
  self.save_leaderboard()
77
 
78
  async def handle_message(self, websocket: WebSocket, data: dict):
79
+ if websocket not in self.active_connections: return
80
+
81
+ p = self.active_connections[websocket]
82
+
83
  if data['type'] == 'update':
84
+ # Trust client physics, but store absolute coordinates
85
+ p['username'] = data.get('username', 'Unknown')
86
+ p['x'] = data.get('x', 0)
87
+ p['y'] = data.get('y', 0) # World Y (Up is positive)
88
+ p['vx'] = data.get('vx', 0)
89
+ p['vy'] = data.get('vy', 0)
90
+ p['faceRight'] = data.get('faceRight', True)
91
+ p['score'] = max(p['score'], data.get('score', 0))
92
+ p['dead'] = False
 
 
 
 
 
93
 
94
  elif data['type'] == 'gameover':
95
+ p['dead'] = True
96
+ self.update_leaderboard(p['username'], data.get('score', 0))
 
 
97
 
98
  async def broadcast_gamestate(self):
99
+ if not self.active_connections: return
 
 
100
 
101
+ # Pack data efficiently
102
  players_list = []
103
  for ws, p in self.active_connections.items():
104
  if not p['dead']:
105
+ players_list.append({
106
+ "u": p['username'],
107
+ "x": int(p['x']),
108
+ "y": int(p['y']),
109
+ "vx": round(p['vx'], 2),
110
+ "vy": round(p['vy'], 2),
111
+ "f": p['faceRight'],
112
+ "s": p['score']
113
+ })
114
+
115
+ msg = {
116
+ "type": "state",
117
+ "ts": time.time(), # Timestamp for interpolation
118
+ "p": players_list,
119
+ "l": self.leaderboard
120
  }
121
 
122
  # Broadcast
123
+ to_remove = []
124
+ for ws in self.active_connections:
125
  try:
126
+ await ws.send_json(msg)
127
  except:
128
+ to_remove.append(ws)
129
 
130
+ for ws in to_remove:
131
+ self.disconnect(ws)
132
 
133
  manager = ConnectionManager()
134
 
135
+ # --- HIGH PERFORMANCE LOOP (60hz) ---
136
  @app.on_event("startup")
137
+ async def start_loop():
138
  asyncio.create_task(broadcast_loop())
139
 
140
  async def broadcast_loop():
141
+ # 60 FPS = ~0.016s
142
  while True:
143
+ start_time = time.time()
144
  await manager.broadcast_gamestate()
145
+ elapsed = time.time() - start_time
146
+ sleep_time = max(0, 0.016 - elapsed)
147
+ await asyncio.sleep(sleep_time)
148
 
149
  @app.websocket("/ws")
150
  async def websocket_endpoint(websocket: WebSocket):
 
155
  await manager.handle_message(websocket, data)
156
  except WebSocketDisconnect:
157
  manager.disconnect(websocket)
158
+ except:
 
159
  manager.disconnect(websocket)
160
 
161
+ # --- FRONTEND ---
 
162
  @app.get("/", response_class=HTMLResponse)
163
  async def get():
164
  return """
 
167
  <head>
168
  <meta charset="UTF-8">
169
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
170
+ <title>Jungle Jump Live</title>
171
  <style>
172
+ body { margin: 0; padding: 0; background-color: #1a1a1a; display: flex; justify-content: center; align-items: center; height: 100vh; overflow: hidden; font-family: 'Segoe UI', sans-serif; touch-action: none; }
173
+ #gameContainer { position: relative; width: 100%; height: 100%; max-width: 50vh; aspect-ratio: 9/16; background: #659d33; box-shadow: 0 0 30px rgba(0,0,0,0.5); overflow: hidden; }
174
  canvas { display: block; width: 100%; height: 100%; }
175
  #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
176
+ #score { position: absolute; top: 20px; right: 20px; font-size: 32px; font-weight: 900; color: #fff; text-shadow: 2px 2px 0 #000; z-index: 5; font-family: monospace;}
177
+ #leaderboard { position: absolute; top: 10px; left: 10px; font-size: 12px; color: #fff; background: rgba(0,0,0,0.6); padding: 8px; border-radius: 6px; z-index: 5; pointer-events: none; }
178
+ #leaderboard h3 { margin: 0 0 5px 0; color: #ffd700; font-size: 14px; text-transform: uppercase; }
179
+ .screen-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.9); display: flex; flex-direction: column; justify-content: center; align-items: center; color: white; z-index: 10; transition: opacity 0.3s; }
180
+ .hidden { opacity: 0; pointer-events: none; }
181
+ h1 { font-size: 40px; margin-bottom: 10px; color: #ffd700; text-transform: uppercase; letter-spacing: 2px; }
182
+ button { padding: 15px 40px; font-size: 20px; background: #ffd700; border: none; border-radius: 30px; cursor: pointer; font-weight: bold; color: #000; transition: transform 0.1s; margin-top: 20px; text-transform: uppercase; }
183
  button:active { transform: scale(0.95); }
184
+ input { padding: 12px; font-size: 18px; border-radius: 8px; border: 2px solid #555; text-align: center; margin-bottom: 10px; background: #333; color: white; width: 200px; outline: none; }
185
+ input:focus { border-color: #ffd700; }
186
+ .status { position: absolute; bottom: 5px; right: 5px; font-size: 10px; color: rgba(255,255,255,0.3); }
187
  </style>
188
  </head>
189
  <body>
 
193
 
194
  <div id="ui-layer">
195
  <div id="score">0</div>
196
+ <div id="leaderboard"><h3>Top Players</h3><div id="lb-content"></div></div>
197
+ <div class="status" id="ping">Ping: 0ms</div>
 
 
 
 
198
  </div>
199
 
200
+ <!-- Login -->
201
+ <div id="loginScreen" class="screen-overlay">
202
+ <h1>Jungle Jump</h1>
203
+ <p>MMO Edition</p>
204
+ <input type="text" id="usernameInput" placeholder="Nickname" maxlength="12">
205
+ <button id="startBtn">Join Game</button>
206
  </div>
207
 
208
+ <!-- Game Over -->
209
+ <div id="gameOverScreen" class="screen-overlay hidden">
210
+ <h1 style="color: #ff4444">FALLEN!</h1>
211
+ <p id="finalScore" style="font-size: 24px;">Score: 0</p>
212
+ <button id="restartBtn">Respawn</button>
213
  </div>
214
  </div>
215
 
216
  <script>
217
+ // --- CORE ENGINE ---
218
+
219
+ // Cookie Helpers
220
+ function setCookie(name, value, days) {
221
+ let expires = "";
222
+ if (days) {
223
+ let date = new Date();
224
+ date.setTime(date.getTime() + (days*24*60*60*1000));
225
+ expires = "; expires=" + date.toUTCString();
226
+ }
227
+ document.cookie = name + "=" + (value || "") + expires + "; path=/";
228
  }
229
+ function getCookie(name) {
230
+ let nameEQ = name + "=";
231
+ let ca = document.cookie.split(';');
232
+ for(let i=0;i < ca.length;i++) {
 
 
233
  let c = ca[i];
234
+ while (c.charAt(0)==' ') c = c.substring(1,c.length);
235
+ if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
236
  }
237
+ return null;
238
  }
239
 
240
+ // Deterministic RNG for Shared Level Generation
241
  class SeededRNG {
242
+ constructor(seed) { this.seed = seed; }
 
 
243
  next() {
244
  this.seed = (this.seed * 9301 + 49297) % 233280;
245
  return this.seed / 233280;
246
  }
247
  }
 
248
 
249
+ // Config
250
+ const GAME_W = 375;
251
+ const GAME_H = 812; // Viewport height
252
+ const GRAVITY = 0.45;
253
+ const JUMP_FORCE = -13;
254
+ const MOVE_SPEED = 7;
255
 
256
+ // DOM
257
  const canvas = document.getElementById('gameCanvas');
258
  const ctx = canvas.getContext('2d', { alpha: false });
 
 
 
259
  const loginScreen = document.getElementById('loginScreen');
260
+ const gameOverScreen = document.getElementById('gameOverScreen');
261
+ const scoreEl = document.getElementById('score');
262
+ const pingEl = document.getElementById('ping');
 
 
 
263
 
264
+ // Game State
265
+ let gameState = 'LOGIN'; // LOGIN, PLAYING, DEAD
266
+ let username = "Player";
267
+ let score = 0;
268
+ let worldSeed = 12345;
269
+ let rng;
270
 
271
+ // Camera
272
+ let cameraY = 0; // World Y at bottom of screen (increases as we go up)
273
+ // In this engine: Y=0 is ground. Y increases upwards.
 
 
274
 
275
+ // Entities
276
+ let player = { x: 0, y: 0, vx: 0, vy: 0, w: 40, h: 40, faceRight: true, dead: false };
277
+ let platforms = [];
278
+ let otherPlayers = {}; // Map { username: {x, y, targetX, targetY, t} }
 
 
279
 
280
+ // Inputs
281
+ let keys = { left: false, right: false };
282
  let socket;
283
+ let lastTime = 0;
 
284
 
285
+ // --- NETWORK ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
+ function connect() {
288
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
289
+ socket = new WebSocket(`${proto}//${window.location.host}/ws`);
 
290
 
291
+ socket.onopen = () => {
292
+ pingEl.style.color = '#0f0';
293
+ // If we have a username and we're just reconnecting?
294
+ if(gameState === 'PLAYING') {
295
+ // Re-auth logic if needed
296
+ }
297
  };
298
 
299
+ socket.onmessage = (e) => {
300
+ const data = JSON.parse(e.data);
301
 
302
  if (data.type === 'init') {
303
+ worldSeed = data.seed;
304
+ updateLeaderboard(data.leaderboard);
305
+ // If auto-login, start game
306
+ if(gameState === 'LOGIN' && username !== "Player") {
307
+ startGameLogic();
308
+ }
309
+ }
310
+ else if (data.type === 'state') {
311
+ handleStateUpdate(data);
312
  }
313
  };
314
+
315
+ socket.onclose = () => {
316
+ pingEl.style.color = 'red';
317
+ setTimeout(connect, 1000); // Auto reconnect
318
+ };
319
  }
320
 
321
+ function handleStateUpdate(data) {
322
+ // Leaderboard
323
+ if(data.l) updateLeaderboard(data.l);
324
+
325
+ // Players
326
+ const now = performance.now();
327
+ data.p.forEach(pData => {
328
+ if(pData.u === username) return; // Ignore self
329
+
330
+ if(!otherPlayers[pData.u]) {
331
+ otherPlayers[pData.u] = {
332
+ x: pData.x, y: pData.y,
333
+ currX: pData.x, currY: pData.y,
334
+ faceRight: pData.f
335
+ };
 
336
  }
337
+
338
+ // Interpolation Targets
339
+ const op = otherPlayers[pData.u];
340
+ op.targetX = pData.x;
341
+ op.targetY = pData.y;
342
+ op.faceRight = pData.f;
343
+ op.lastUpdate = now;
344
+ });
345
+
346
+ // Clean up old players
347
+ for(let u in otherPlayers) {
348
+ if(now - otherPlayers[u].lastUpdate > 2000) delete otherPlayers[u];
349
  }
350
  }
351
 
352
+ function sendUpdate() {
353
+ if(socket && socket.readyState === 1 && gameState === 'PLAYING') {
354
  socket.send(JSON.stringify({
355
+ type: 'update',
356
+ username: username,
357
+ x: Math.round(player.x),
358
+ y: Math.round(player.y),
359
+ vx: player.vx,
360
+ vy: player.vy,
361
+ faceRight: player.faceRight,
362
+ score: Math.floor(score)
363
  }));
364
  }
365
  }
366
 
367
+ function updateLeaderboard(list) {
368
+ const el = document.getElementById('lb-content');
369
+ el.innerHTML = list.map((e,i) => `<div>${i+1}. ${e.username} <span style="color:#ffd700">${e.score}</span></div>`).join('');
 
 
 
 
 
 
 
370
  }
371
 
372
+ // --- GAME LOGIC ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
374
+ function checkAutoLogin() {
375
+ const saved = getCookie("username");
376
+ if(saved) {
377
+ username = saved;
378
+ document.getElementById('usernameInput').value = saved;
379
+ loginScreen.classList.add('hidden');
380
+ startGameLogic();
 
 
 
 
 
 
 
381
  }
382
+ }
383
 
384
+ function startGameBtn() {
385
+ const input = document.getElementById('usernameInput');
386
+ const name = input.value.trim().slice(0, 12) || "Guest";
387
+ username = name;
388
+ setCookie("username", name, 7);
389
+ loginScreen.classList.add('hidden');
390
+ startGameLogic();
 
 
 
 
391
  }
392
 
393
+ function startGameLogic() {
394
+ // Reset Physics
395
+ gameState = 'PLAYING';
396
+ player.x = GAME_W/2;
397
+ player.y = 100; // Start slightly above ground
398
+ player.vx = 0;
399
+ player.vy = 0;
400
+ player.dead = false;
401
+ cameraY = 0;
402
+ score = 0;
403
+ scoreEl.innerText = "0";
404
+ gameOverScreen.classList.add('hidden');
405
 
406
+ // Reset Level
407
+ rng = new SeededRNG(worldSeed);
408
+ platforms = [];
 
409
 
410
+ // Generate initial chunk
411
+ generatePlatforms(0, 2000);
412
+ }
413
 
414
+ function generatePlatforms(minY, maxY) {
415
+ // Use the seeded RNG to deterministically place platforms based on height
416
+ // We step through height in chunks
417
+ let y = minY;
418
+ // Align y to a grid roughly to avoid overlapping calls
419
+ y = Math.floor(y / 100) * 100;
420
 
421
+ while(y < maxY) {
422
+ // Deterministic check: hash the height
423
+ // Simple hash of Y coordinate to see if platform exists
424
+ // (We re-seed RNG to Y to make it stateless effectively)
425
+ let r = Math.sin(y * 12.9898 + worldSeed) * 43758.5453;
426
+ let randVal = r - Math.floor(r); // 0.0 - 1.0 deterministic
427
+
428
+ if (randVal > 0.2 || y < 300) { // 80% chance of platform, guaranteed at start
429
+ let r2 = Math.sin(y * 78.233 + worldSeed) * 43758.5453;
430
+ let xVal = (r2 - Math.floor(r2)) * (GAME_W - 60);
431
+
432
+ // Check if already exists (naive)
433
+ if(!platforms.some(p => Math.abs(p.y - y) < 1)) {
434
+ platforms.push({ x: xVal, y: y, w: 60, h: 15, type: 0 });
435
+ }
436
+ }
437
+ y += 60 + (randVal * 60); // Randomish spacing
438
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
+ // Cleanup old platforms
441
+ platforms = platforms.filter(p => p.y > cameraY - 100);
442
  }
443
 
444
+ function updatePhysics() {
445
+ if(gameState !== 'PLAYING') return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
 
447
+ // Horizontal
448
+ if(keys.left) player.vx = -MOVE_SPEED;
449
+ else if(keys.right) player.vx = MOVE_SPEED;
450
+ else player.vx *= 0.8;
 
 
451
 
452
+ player.x += player.vx;
 
 
 
 
 
 
 
453
 
454
+ // Wrap
455
+ if(player.x < -player.w/2) player.x = GAME_W - player.w/2;
456
+ if(player.x > GAME_W - player.w/2) player.x = -player.w/2;
 
 
 
 
457
 
458
+ player.faceRight = player.vx > 0;
 
459
 
460
+ // Vertical (Gravity is down, so vy decreases)
461
+ // Wait, coordinate system: Up is Positive Y.
462
+ // So Gravity decreases Y velocity.
463
+ player.vy -= GRAVITY;
464
+ player.y += player.vy;
465
 
466
+ // Bounce
467
+ if(player.vy < 0) { // Only bounce if falling
468
+ for(let p of platforms) {
469
+ // Simple AABB
470
+ if(player.x + player.w/2 > p.x && player.x + player.w/2 < p.x + p.w &&
471
+ player.y >= p.y && player.y + player.vy <= p.y + 10) { // Detection window
472
+ player.vy = -JUMP_FORCE; // Jump force is positive in this system
473
+ player.y = p.y;
474
+ break;
475
+ }
476
+ }
477
  }
 
 
 
 
 
 
 
 
 
 
478
 
479
+ // Camera follow (Soft lock)
480
+ let targetCamY = player.y - GAME_H * 0.4;
481
+ if(targetCamY > cameraY) {
482
+ cameraY = targetCamY;
483
+ score = Math.max(score, cameraY);
484
+ scoreEl.innerText = Math.floor(score);
485
+ generatePlatforms(cameraY + GAME_H, cameraY + GAME_H + 500);
 
 
 
 
486
  }
487
 
488
+ // Death (Fall below camera)
489
+ if(player.y < cameraY - 100) {
490
+ die();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  }
492
  }
493
 
494
+ function die() {
495
+ gameState = 'DEAD';
496
+ player.dead = true;
497
+ socket.send(JSON.stringify({ type: 'gameover', score: Math.floor(score) }));
498
+ document.getElementById('finalScore').innerText = "Score: " + Math.floor(score);
499
+ gameOverScreen.classList.remove('hidden');
 
 
 
 
 
 
500
  }
501
 
502
+ // --- RENDERING ---
 
 
 
 
 
 
 
 
503
 
504
+ function draw() {
505
+ // Clear
506
+ ctx.fillStyle = '#659d33';
507
+ ctx.fillRect(0, 0, GAME_W, GAME_H);
508
+
509
+ // Grid
510
+ ctx.strokeStyle = 'rgba(0,0,0,0.1)';
511
+ ctx.beginPath();
512
+ let gridOffset = cameraY % 100;
513
+ for(let i=0; i<GAME_H; i+=100) {
514
+ let y = GAME_H - (i - gridOffset); // Convert World Y to Screen Y
515
+ ctx.moveTo(0, y); ctx.lineTo(GAME_W, y);
 
516
  }
517
+ ctx.stroke();
518
 
519
+ // Platforms
520
+ ctx.fillStyle = '#76c442';
521
+ ctx.strokeStyle = '#2e5c10';
522
+ ctx.lineWidth = 2;
 
 
523
 
524
+ for(let p of platforms) {
525
+ // Convert World Y to Screen Y
526
+ // ScreenY = GAME_H - (ObjectY - CameraY)
527
+ let screenY = GAME_H - (p.y - cameraY);
 
 
 
 
 
 
 
 
 
528
 
529
+ if(screenY > -50 && screenY < GAME_H + 50) {
530
+ ctx.beginPath();
531
+ ctx.roundRect(p.x, screenY, p.w, p.h, 5);
532
+ ctx.fill();
533
+ ctx.stroke();
534
+
535
+ // Detail
536
+ ctx.fillStyle = '#9fe060';
537
+ ctx.beginPath();
538
+ ctx.roundRect(p.x+3, screenY+3, p.w-6, 5, 2);
539
+ ctx.fill();
540
+ ctx.fillStyle = '#76c442'; // Reset
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  }
 
 
 
 
 
 
 
 
 
 
 
542
  }
543
 
544
+ // Other Players (Interpolated)
545
+ for(let u in otherPlayers) {
546
+ let op = otherPlayers[u];
547
+
548
+ // Linear Interpolation (Smooth movement)
549
+ op.currX += (op.targetX - op.currX) * 0.2; // 20% closer per frame
550
+ op.currY += (op.targetY - op.currY) * 0.2;
551
+
552
+ let screenY = GAME_H - (op.currY - cameraY);
553
+
554
+ // Visibility Check
555
+ if(screenY > -100 && screenY < GAME_H + 100) {
556
+ drawDoodler(op.currX, screenY, op.faceRight, false, u);
 
 
 
 
 
 
 
557
  }
 
 
 
 
 
558
  }
559
 
560
+ // Self
561
+ if(gameState === 'PLAYING') {
562
+ let screenY = GAME_H - (player.y - cameraY);
563
+ drawDoodler(player.x, screenY, player.faceRight, true, username);
564
+ }
565
 
566
+ requestAnimationFrame(loop);
 
 
 
 
567
  }
568
 
569
+ function drawDoodler(x, y, faceRight, isSelf, name) {
570
+ ctx.save();
571
+ ctx.translate(x + 20, y - 40); // Pivot at feet roughly
572
+ if(!faceRight) ctx.scale(-1, 1);
573
 
574
+ // Body
575
+ ctx.fillStyle = isSelf ? '#ffd700' : '#a0e0a0';
576
+ ctx.strokeStyle = '#000';
577
+ ctx.lineWidth = 2;
578
+
579
+ // Simple Doodle Shape
580
+ ctx.beginPath();
581
+ ctx.moveTo(-15, 0); ctx.lineTo(-15, -30);
582
+ ctx.bezierCurveTo(-15, -45, 15, -45, 15, -30);
583
+ ctx.lineTo(15, 0);
584
+ ctx.bezierCurveTo(0, 10, 0, 10, -15, 0);
585
+ ctx.fill(); ctx.stroke();
586
+
587
+ // Backpack
588
+ ctx.fillStyle = '#555';
589
+ ctx.fillRect(-18, -25, 4, 15);
590
+
591
+ // Snout
592
+ ctx.fillStyle = isSelf ? '#ffd700' : '#a0e0a0';
593
+ ctx.beginPath(); ctx.rect(10, -28, 10, 10); ctx.fill(); ctx.stroke();
594
+
595
+ // Eye
596
+ ctx.fillStyle = 'white';
597
+ ctx.beginPath(); ctx.arc(5, -22, 5, 0, Math.PI*2); ctx.fill(); ctx.stroke();
 
 
598
  ctx.fillStyle = 'black';
599
+ ctx.beginPath(); ctx.arc(7, -22, 2, 0, Math.PI*2); ctx.fill();
600
+
601
+ ctx.restore();
602
+
603
+ // Name Tag
604
+ if(name) {
605
+ ctx.fillStyle = "rgba(0,0,0,0.5)";
606
+ ctx.font = "bold 10px sans-serif";
607
+ ctx.textAlign = "center";
608
+ ctx.fillText(name, x + 20, y - 50);
 
 
 
 
 
 
 
 
 
 
 
609
  }
610
  }
611
 
612
+ function loop() {
613
+ updatePhysics();
614
  draw();
615
+
616
+ // Throttle network updates to 20hz to save bandwidth, rendering is 60hz
617
+ const now = Date.now();
618
+ if(now - lastTime > 50) {
619
+ sendUpdate();
620
+ lastTime = now;
621
  }
622
  }
623
 
624
+ // --- INPUTS ---
625
+
626
+ window.addEventListener('resize', () => {
627
  canvas.width = GAME_W;
628
  canvas.height = GAME_H;
629
+ });
630
+ window.dispatchEvent(new Event('resize'));
631
 
 
632
  window.addEventListener('keydown', e => {
633
+ if(e.key === "ArrowLeft") keys.left = true;
634
+ if(e.key === "ArrowRight") keys.right = true;
 
635
  });
636
  window.addEventListener('keyup', e => {
637
+ if(e.key === "ArrowLeft") keys.left = false;
638
+ if(e.key === "ArrowRight") keys.right = false;
639
  });
640
 
641
+ // Touch
 
 
 
 
 
 
 
 
 
 
642
  canvas.addEventListener('touchstart', e => {
643
  e.preventDefault();
644
+ let x = (e.touches[0].clientX - canvas.getBoundingClientRect().left) * (GAME_W / canvas.offsetWidth);
645
+ if(x < GAME_W/2) { keys.left = true; keys.right = false; }
646
+ else { keys.right = true; keys.left = false; }
647
+ });
 
 
 
 
 
648
  canvas.addEventListener('touchend', e => {
649
  e.preventDefault();
650
+ keys.left = false; keys.right = false;
651
  });
652
 
653
+ document.getElementById('startBtn').addEventListener('click', startGameBtn);
654
+ document.getElementById('restartBtn').addEventListener('click', startGameLogic);
 
 
 
 
 
 
655
 
656
+ // Init
657
+ connect();
658
+ checkAutoLogin(); // Will skip UI if cookie exists
659
+ requestAnimationFrame(loop);
 
660
 
661
  </script>
662
  </body>
663
  </html>
664
+ """
665
 
666
  if __name__ == "__main__":
667
  import uvicorn