My own fast-running platformer in the spirit of Sonic the Hedgehog (Sega, 1991), with my own runners, zones, robots, art and music. Play it at https://games.503mail.com/dash/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.
What's in it
Dash the armadillo (or Zippy, Bolt or Nova) races through 8 zones of 3 acts: Meadow Run, Sunny Beach, Crystal Mine, Neon City, Lava Ridge, Frost Peak (slippery ice), Cloud Fort and Gear Factory. That's 24 acts, more than the original's 18. The ground is made of real slopes, so running downhill builds speed and uphill slows you down. There are loops to run all the way round (a speed pad before each one), launch ramps, springs up to bonus platforms with rings, moving platforms, spikes and bottomless pits. Collect rings: when a robot or spikes hit you, your rings scatter and you can grab some back, and getting hit with no rings costs a life. 100 rings is an extra life. Rolling into robots or jumping on them sets free the bird inside. Item boxes hold 10 rings, a shield, speed shoes, invincibility or an extra life. There's a checkpoint post halfway and a goal sign at the end, with time and ring bonuses. Every third act ends in an arena with Professor Gearhead in his hover pod, dropping bombs (and swooping, later on). Jump into him 8 times. Race Together: up to 4 players race the same act, each on their own tablet or PC, and see the others as see-through ghosts with name tags. The top corner shows your place, and the finish screen lists everyone's times before the next act starts. Progress and options are saved in the browser. Signed in from BlockCraft: like Super Pip, a kid signed in to BlockCraft on the same tablet races under their BlockCraft name, as a runner in their own BlockCraft colour, without typing anything.
Controls
Keyboard: ◀ ▶ run, Space / Z / ▲ jump, ▼ while running rolls into a ball. Spin dash: stand still, hold ▼, tap jump a few times to rev up, then let go of ▼. P pauses; a game controller works too. Touch: ◀ ▶ and ▼ on the left, a big JUMP button on the right, and II to pause. Easy gives endless lives. Race Together: one player taps Race Together, then Start a new race. The others tap Race Together and Join it. The first player picks an act and taps Start, and everyone gets a 3-2-1-GO countdown.
Rebuild, step by step
Dash Rush is one folder on the games site. Build the site first with Games site + Nibbles. That post creates CT 125 (nibbles), the menu, 05-push-site.sh and make-icons.ps1. The menu already has the Dash Rush tile.
1. Make the game's folder
Proxmox host shell (Proxmox web UI → pve → Shell):
mkdir -p /root/nibbles/dash
2. Create the game file
Paste this into the Proxmox host shell. It writes /root/nibbles/dash/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):
cat > /root/nibbles/dash/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Dash Rush</title>
<link rel="icon" type="image/png" sizes="32x32" href="/dash/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/dash/icon-192.png">
<link rel="apple-touch-icon" href="/dash/apple-touch-icon.png">
<meta property="og:title" content="Dash Rush">
<meta property="og:image" content="https://games.503mail.com/dash/icon-512.png">
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden;touch-action:none;color:#fff;
font-family:"Lucida Console","Courier New",monospace;user-select:none;-webkit-user-select:none}
#c{position:absolute;image-rendering:pixelated;image-rendering:crisp-edges}
.ov{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:1.6vmin;background:rgba(0,0,30,.5);overflow:auto;padding:16px;box-sizing:border-box}
.hide{display:none!important}
h1{margin:0 0 1vmin;font-size:clamp(28px,10vmin,72px);color:#ffe040;font-style:italic;letter-spacing:.05em;
text-shadow:3px 3px 0 #2050d0,6px 6px 0 #000}
h2{margin:0;font-size:clamp(18px,5vmin,34px);color:#ffe040}
button,select,input{font:inherit;font-size:clamp(16px,3.4vmin,24px)}
.big{min-width:min(70vw,360px);padding:1.4vmin 3vmin;background:#2050d0;color:#fff;border:3px solid #fff;
border-radius:10px;box-shadow:0 4px 0 #0a2060;cursor:pointer}
.big:focus,.big:hover{background:#3a70ff;outline:none}
.row{display:flex;flex-wrap:wrap;gap:1.4vmin;justify-content:center;align-items:center}
.ch{padding:1vmin 2vmin;border:3px solid #fff;border-radius:8px;color:#fff;cursor:pointer;background:#222}
.ch[aria-pressed=true]{outline:4px solid #ffe040}
label{font-size:clamp(14px,3vmin,20px);display:flex;gap:6px;align-items:center}
input[type=checkbox]{width:22px;height:22px}
.lv{display:grid;grid-template-columns:auto repeat(3,auto);gap:1vmin;align-items:center}
.lv button{padding:1vmin 2vmin;background:#2a4ab0;color:#fff;border:2px solid #fff;border-radius:6px}
.lv button:disabled{background:#333;color:#888;border-color:#555}
.lv span{font-size:clamp(12px,2.6vmin,18px);text-align:right;padding-right:1vmin}
a{color:#9cf}
#rooms{display:flex;flex-direction:column;gap:1vmin;min-width:min(80vw,420px)}
#rooms div{display:flex;justify-content:space-between;align-items:center;gap:2vmin;background:#123;padding:1vmin 2vmin;border-radius:6px}
#touch{position:absolute;inset:0;pointer-events:none}
#touch.on{pointer-events:auto}
.tb{position:absolute;border-radius:50%;background:rgba(255,255,255,.16);border:3px solid rgba(255,255,255,.45);
color:rgba(255,255,255,.85);display:flex;align-items:center;justify-content:center;font-weight:bold;
width:16vmin;height:16vmin;font-size:6vmin;bottom:4vmin}
.tb.down{background:rgba(255,255,255,.45)}
#tbL{left:3vmin}#tbR{left:23vmin}#tbD{left:13vmin;bottom:21vmin;width:13vmin;height:13vmin}#tbA{right:4vmin;width:20vmin;height:20vmin}
#pauseBtn{position:absolute;top:1vmin;right:1vmin;width:9vmin;height:9vmin;font-size:4vmin;border-radius:8px;
background:rgba(0,0,0,.35);color:#fff;border:2px solid rgba(255,255,255,.5)}
</style>
</head>
<body>
<canvas id="c" aria-label="Dash Rush game screen"></canvas>
<div id="touch" class="hide">
<div class="tb" id="tbL">◀</div><div class="tb" id="tbR">▶</div><div class="tb" id="tbD">▼</div>
<div class="tb" id="tbA">JUMP</div>
</div>
<button id="pauseBtn" class="hide" aria-label="Pause">II</button>
<div id="menu" class="ov">
<h1>DASH RUSH</h1>
<div class="row" id="chars" role="group" aria-label="Pick your runner"></div>
<button class="big" id="bPlay">Play</button>
<button class="big" id="bMP">Race Together</button>
<button class="big" id="bLevels">Choose Act</button>
<div class="row">
<label><input type="checkbox" id="oEasy"> Easy (endless lives)</label>
<label><input type="checkbox" id="oSnd"> Sound</label>
</div>
<div style="font-size:clamp(12px,2.6vmin,16px);text-align:center;max-width:90vw">
Keys: arrows to run, Space/Z/Up jump, Down to roll. Spin dash: hold Down, tap Jump, let go of Down. P pause.</div>
<a href="/">All games</a>
</div>
<div id="levels" class="ov hide">
<h2>Choose Act</h2>
<div class="lv" id="lvGrid"></div>
<button class="big" id="bLvBack">Back</button>
</div>
<div id="lobby" class="ov hide">
<h2>Race Together</h2>
<div id="lbStatus" role="status">Connecting...</div>
<div class="row"><label>Your name <input id="lbName" maxlength="12" size="12"></label></div>
<div id="lbSigned" class="hide" style="font-size:clamp(12px,2.6vmin,16px)">Signed in from BlockCraft on this tablet.</div>
<div id="lbOut">
<div class="row"><button class="big" id="bCreate">Start a new race</button></div>
<div id="rooms" aria-label="Races you can join"></div>
</div>
<div id="lbIn" class="hide">
<div id="lbRoom" style="text-align:center"></div>
<div class="row" id="lbHost"><label>Act <select id="lbLevel"></select></label>
<button class="big" id="bStartMP">Start</button></div>
</div>
<button class="big" id="bLbBack">Back</button>
</div>
<div id="pause" class="ov hide">
<h2>Paused</h2>
<button class="big" id="bResume">Resume</button>
<button class="big" id="bRestart">Restart act</button>
<button class="big" id="bQuit">Quit to menu</button>
</div>
<script>
'use strict';
// ===================================================================================
// DASH RUSH - our own fast-running platformer (own characters, zones, art, music).
// 24 acts in 8 zones (a boss at the end of every zone), races for up to 4 players.
// ===================================================================================
const $ = id => document.getElementById(id);
const VH = 240, WH = 640, NONE = 30000;
let VW = 400;
const cv = $('c'), ctx = cv.getContext('2d');
function resize() {
const iw = innerWidth || 640, ih = innerHeight || 360;
VW = Math.max(300, Math.min(480, Math.round(VH * iw / ih / 2) * 2));
cv.width = VW; cv.height = VH; ctx.imageSmoothingEnabled = false;
const s = Math.min(iw / VW, ih / VH);
cv.style.width = (VW * s) + 'px'; cv.style.height = (VH * s) + 'px';
cv.style.left = ((iw - VW * s) / 2) + 'px'; cv.style.top = ((ih - VH * s) / 2) + 'px';
}
addEventListener('resize', resize); resize();
function mulberry(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
let SAVE = { un: 0, cur: 0, name: '', ch: 0, easy: false, snd: true };
try { Object.assign(SAVE, JSON.parse(localStorage.getItem('dashrush') || '{}')); } catch (e) {}
function save() { try { localStorage.setItem('dashrush', JSON.stringify(SAVE)); } catch (e) {} }
// ---------------- audio ----------------
let AC = null, master = null;
function ac() {
if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); master = AC.createGain(); master.gain.value = 0.22; master.connect(AC.destination); } catch (e) { return null; } }
if (AC.state === 'suspended') AC.resume(); return AC;
}
function tone(f0, f1, dur, type, vol, delay) {
if (!SAVE.snd) return; const a = ac(); if (!a) return;
const t = a.currentTime + (delay || 0), o = a.createOscillator(), g = a.createGain();
o.type = type || 'square'; o.frequency.setValueAtTime(f0, t); if (f1) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
g.gain.setValueAtTime(vol || 0.2, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g); g.connect(master); o.start(t); o.stop(t + dur + 0.02);
}
let noiseBuf = null;
function noise(dur, vol, delay) {
if (!SAVE.snd) return; const a = ac(); if (!a) return;
if (!noiseBuf) { noiseBuf = a.createBuffer(1, a.sampleRate * 0.5, a.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
const t = a.currentTime + (delay || 0), s = a.createBufferSource(), g = a.createGain();
s.buffer = noiseBuf; g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); s.connect(g); g.connect(master); s.start(t); s.stop(t + dur);
}
let ringFlip = 0;
const SFX = {
jump: () => tone(300, 900, 0.14, 'square', 0.12),
ring: () => { ringFlip ^= 1; tone(ringFlip ? 1760 : 2093, 0, 0.05, 'triangle', 0.25); tone(ringFlip ? 2637 : 2349, 0, 0.18, 'triangle', 0.2, 0.05); },
spin: () => tone(400, 1200, 0.18, 'sawtooth', 0.08),
dash: () => { tone(1200, 200, 0.3, 'sawtooth', 0.1); noise(0.2, 0.15); },
roll: () => tone(500, 250, 0.12, 'square', 0.08),
spring: () => tone(200, 1000, 0.25, 'square', 0.14),
pop: () => { noise(0.25, 0.25); tone(300, 80, 0.2, 'square', 0.15); },
mon: () => { noise(0.2, 0.2); tone(600, 1200, 0.2, 'square', 0.1); },
hurt: () => { for (let i = 0; i < 6; i++) tone(1500 + Math.random() * 1500, 0, 0.12, 'triangle', 0.1, i * 0.03); },
die: () => tone(800, 100, 0.8, 'square', 0.15),
check: () => [880, 1109, 1319].forEach((f, i) => tone(f, 0, 0.12, 'square', 0.1, i * 0.08)),
sign: () => { for (let i = 0; i < 10; i++) tone(500 + i * 60, 0, 0.05, 'square', 0.08, i * 0.06); },
boss: () => { noise(0.3, 0.3); tone(150, 50, 0.3, 'sawtooth', 0.2); },
boom: () => { noise(0.5, 0.35); tone(120, 40, 0.5, 'square', 0.2); },
life: () => [523, 659, 784, 1047, 784, 1047].forEach((f, i) => tone(f, 0, 0.12, 'square', 0.12, i * 0.1)),
pad: () => tone(300, 1500, 0.2, 'sawtooth', 0.1),
beep: () => tone(880, 0, 0.12, 'square', 0.15), go: () => tone(1760, 0, 0.3, 'square', 0.15),
clear: () => [784, 988, 1175, 1568, 0, 1175, 1568].forEach((f, i) => f && tone(f, 0, 0.15, 'square', 0.13, i * 0.12))
};
function sfx(n) { if (SFX[n]) SFX[n](); }
const NOTE = { C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11 };
function n2f(n) { const m = /^([A-G]#?)(\d)$/.exec(n); if (!m) return 0; return 440 * Math.pow(2, (NOTE[m[1]] + (+m[2] + 1) * 12 - 69) / 12); }
// our own tunes: 16 eighths a bar in the melody, bass notes are 2 eighths each
const SONGS = {
run: { step: 0.11, mel: 'E5:2 G5:1 A5:1 B5:2 A5:1 G5:1 E5:2 D5:2 E5:4 G5:2 A5:1 B5:1 D6:2 B5:1 A5:1 G5:2 A5:2 B5:4 C6:2 B5:1 A5:1 G5:2 E5:2 D5:2 E5:2 G5:4 A5:1 G5:1 E5:1 D5:1 E5:2 B4:2 D5:2 E5:2 E5:4',
bass: 'E2 E3 E2 E3 C3 C3 D3 D3 E2 E3 E2 E3 G2 G2 B2 B2 A2 A3 A2 A3 E2 E3 E2 E3 C3 C3 D3 D3 E2 E3 E2 E2' },
beach: { step: 0.12, mel: 'C5:1 E5:1 G5:1 E5:1 A5:2 G5:2 F5:1 A5:1 C6:1 A5:1 G5:4 E5:1 G5:1 C6:1 G5:1 D6:2 C6:2 B5:1 A5:1 G5:1 F5:1 E5:4',
bass: 'C3 G2 C3 G2 F2 C3 F2 C3 C3 G2 C3 G2 G2 D3 G2 D3' },
cave: { step: 0.13, mel: 'D5:2 F5:2 A5:3 G5:1 F5:2 E5:2 D5:4 C5:2 E5:2 G5:3 F5:1 E5:2 C5:2 D5:4',
bass: 'D2 D2 A2 A2 D2 D2 A2 A2 C2 C2 G2 G2 D2 D2 A2 A2' },
city: { step: 0.1, mel: 'A4:1 A4:1 C5:1 A4:1 D5:1 A4:1 E5:1 D5:1 C5:2 A4:2 G4:2 A4:2 A4:1 A4:1 C5:1 A4:1 E5:1 A4:1 G5:1 E5:1 F5:2 E5:2 D5:2 E5:2',
bass: 'A2 A2 A2 A2 F2 F2 G2 G2 A2 A2 A2 A2 F2 F2 E2 E2' },
boss: { step: 0.11, mel: 'C5:1 C5:1 D#5:1 C5:1 F5:1 C5:1 F#5:1 F5:1 C5:1 C5:1 D#5:1 C5:1 G5:2 F#5:2',
bass: 'C2 C2 C2 C2 C2 C2 C2 C2' },
star: { step: 0.09, mel: 'G5:1 G5:1 A5:1 G5:1 C6:1 G5:1 A5:1 B5:1 G5:1 G5:1 A5:1 G5:1 D6:1 C6:1 B5:1 A5:1', bass: 'C3 C3 G2 G2 C3 C3 G2 G2' }
};
function parseSong(s) { return { step: s.step, tracks: [{ notes: s.mel.split(' ').map(t => { const [n, d] = t.split(':'); return [n === '-' ? 0 : n2f(n), +d]; }), type: 'square', vol: 0.06 }, { notes: s.bass.split(' ').map(n => [n2f(n), 2]), type: 'triangle', vol: 0.16 }] }; }
let music = null;
function playMusic(name) { stopMusic(); if (!SAVE.snd || !SONGS[name]) return; const a = ac(); if (!a) return; const s = parseSong(SONGS[name]);
music = { name, s, pos: s.tracks.map(() => ({ i: 0, t: a.currentTime + 0.08 })), timer: setInterval(schedMusic, 60) }; schedMusic(); }
function schedMusic() {
if (!music || !AC) return; const now = AC.currentTime;
music.s.tracks.forEach((tr, k) => { const p = music.pos[k]; if (p.t < now - 0.5) p.t = now + 0.05;
while (p.t < now + 0.25) { const [f, d] = tr.notes[p.i], dur = d * music.s.step;
if (f) { const o = AC.createOscillator(), g = AC.createGain(); o.type = tr.type; o.frequency.value = f; g.gain.setValueAtTime(tr.vol, p.t); g.gain.setValueAtTime(tr.vol, p.t + dur * 0.7); g.gain.linearRampToValueAtTime(0.0001, p.t + dur * 0.95); o.connect(g); g.connect(master); o.start(p.t); o.stop(p.t + dur); }
p.t += dur; p.i = (p.i + 1) % tr.notes.length; } });
}
function stopMusic() { if (music) { clearInterval(music.timer); music = null; } }
// ---------------- zones ----------------
const ZONES = [
{ name: 'Meadow Run', sky: ['#3a8cff', '#bfe6ff'], top: '#38c040', top2: '#1f8a2a', d1: '#c87830', d2: '#a05a1c', far: '#2e8a50', far2: '#5cb870', bg: 'hills', music: 'run' },
{ name: 'Sunny Beach', sky: ['#40b8ff', '#e8f8ff'], top: '#f4dc98', top2: '#d8b860', d1: '#e0b870', d2: '#cc9c58', far: '#1e7acf', far2: '#5ab0ef', bg: 'sea', music: 'beach' },
{ name: 'Crystal Mine', sky: ['#0a0418', '#2a1048'], top: '#a070f0', top2: '#6a3ab0', d1: '#4a2a7a', d2: '#3a1a62', far: '#22123e', far2: '#5a3a9a', bg: 'cave', music: 'cave' },
{ name: 'Neon City', sky: ['#080828', '#40206a'], top: '#30e0e0', top2: '#1a8a9a', d1: '#2c2c50', d2: '#22223e', far: '#14143a', far2: '#ff40c0', bg: 'city', music: 'city' },
{ name: 'Lava Ridge', sky: ['#2a0606', '#c84a20'], top: '#7a4a32', top2: '#4a2414', d1: '#4e2a1a', d2: '#3e1e12', far: '#3a1008', far2: '#ff6020', bg: 'volcano', music: 'cave', lava: 1 },
{ name: 'Frost Peak', sky: ['#7ab8ff', '#ffffff'], top: '#ffffff', top2: '#b8d8f8', d1: '#9ac0e8', d2: '#86acd8', far: '#c8dcf4', far2: '#ffffff', bg: 'peaks', music: 'beach', ice: 1 },
{ name: 'Cloud Fort', sky: ['#ff9ac8', '#fff2c0'], top: '#f8f8ff', top2: '#c8c8f0', d1: '#d0d0e8', d2: '#bcbcdc', far: '#ffffff', far2: '#ffd0e0', bg: 'clouds', music: 'run' },
{ name: 'Gear Factory', sky: ['#1a1e24', '#4e5660'], top: '#c8c8c8', top2: '#808080', d1: '#6a6a72', d2: '#585860', far: '#2a2e34', far2: '#ffb020', bg: 'factory', music: 'city' }];
const CHARS = [
{ name: 'Dash', body: '#20b0a0', dark: '#0a6a60', scarf: '#ff3a3a' },
{ name: 'Zippy', body: '#e04848', dark: '#8a1a1a', scarf: '#ffd030' },
{ name: 'Bolt', body: '#f0c020', dark: '#9a7808', scarf: '#3a70ff' },
{ name: 'Nova', body: '#e060c0', dark: '#8a2a78', scarf: '#ffffff' }];
// Signed in to BlockCraft on this tablet? It's the same site, so the same browser storage. Then the kid races
// as themself: their BlockCraft name, and their BlockCraft colour (same picker BlockCraft uses for vehicles and flags).
const BC_PAINTS = ['#d23a32', '#2f6fd8', '#3fae3a', '#8932b8', '#f0c020', '#f07d19', '#f38baa', '#20b8b0'];
function bcPaint(n) { const s = n.toLowerCase(); let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return BC_PAINTS[(h >>> 0) % 8]; }
function darker(hex) { const n = parseInt(hex.slice(1), 16); return 'rgb(' + ((n >> 16) * 0.5 | 0) + ',' + ((n >> 8 & 255) * 0.5 | 0) + ',' + ((n & 255) * 0.5 | 0) + ')'; }
const BC = (() => { try { const c = JSON.parse(localStorage.getItem('blockcraft_cfg') || '{}'); if (c.signedIn && c.name) return { name: String(c.name).slice(0, 12), color: bcPaint(String(c.name)) }; } catch (e) {} return null; })();
if (BC) { CHARS.unshift({ name: BC.name, body: BC.color, dark: darker(BC.color), scarf: '#ffffff', kid: true });
if (SAVE.kid !== BC.name || !SAVE.kidCh) { SAVE.kid = BC.name; SAVE.ch = 0; } SAVE.kidCh = true; save(); }
else if (SAVE.kidCh) { SAVE.kidCh = false; SAVE.ch = Math.max(0, (SAVE.ch | 0) - 1); save(); }
if (!(SAVE.ch >= 0 && SAVE.ch < CHARS.length)) SAVE.ch = 0;
function charOf(ch) { return Array.isArray(ch) ? { body: ch[0], dark: ch[1], scarf: ch[2] } : CHARS[ch] || CHARS[0]; } // remote players send their colours
function aiName(ai) { return ZONES[Math.floor(ai / 3)].name + ' ' + (ai % 3 + 1); }
// ---------------- act generator ----------------
function genAct(ai) {
const zone = Math.floor(ai / 3), act = ai % 3, Z = ZONES[zone], boss = act === 2;
const rnd = mulberry(ai * 2654435 + 99), R = (a, b) => a + Math.floor(rnd() * (b - a + 1)), ch = p => rnd() < p;
const LEN = boss ? 6500 + zone * 300 : 9000 + zone * 600 + act * 500;
const H = new Float32Array(LEN + 3500).fill(NONE);
const lv = { H, len: 0, plats: [], loops: [], rings: [], ents: [], springs: [], pads: [], spikes: [], mons: [], checks: [], goal: 0, boss: null, zone, act, ai, startX: 80, lava: !!Z.lava, ice: !!Z.ice, eid: 1 };
let x = 0, y = 420, lastMon = 0, checked = false;
const put = (i, h) => { if (i >= 0 && i < H.length) H[i] = h; };
const line = (len, dy) => { for (let i = 0; i < len; i++) put(x + i, y + dy * i / len); x += len; y += dy; };
const curve = (len, dy) => { for (let i = 0; i < len; i++) put(x + i, y + dy * (1 - Math.cos(Math.PI * i / len)) / 2); x += len; y += dy; };
const cy = dy => clamp(y + dy, 240, 540) - y;
const groundRings = (x0, x1, step) => { for (let i = x0; i < x1; i += step || 26) if (H[i | 0] !== NONE) lv.rings.push({ x: i, y: H[i | 0] - 24 }); };
const arcRings = (x0, x1, yy, hgt) => { const n = Math.max(3, Math.floor((x1 - x0) / 26)); for (let i = 0; i <= n; i++) { const t = i / n; lv.rings.push({ x: x0 + (x1 - x0) * t, y: yy - Math.sin(t * Math.PI) * hgt }); } };
const enemy = (ex) => { if (H[ex | 0] === NONE) return; const k = rnd(); const ty = zone >= 3 && k < 0.25 ? 'roller' : k < 0.6 ? 'crab' : 'bee';
const e = { id: lv.eid++, type: ty, x: ex, x0: ex, y: H[ex | 0], dir: -1, t: R(0, 100), alive: true };
if (ty === 'bee') { e.y = H[ex | 0] - R(40, 70); e.by = e.y; } lv.ents.push(e); };
const monitor = (mx) => { const types = ['ring', 'ring', 'ring', 'shield', 'shoes', 'star', 'life']; const k = types[Math.floor(rnd() * (ch(0.15) ? 7 : 5))];
lv.mons.push({ x: mx, y: H[mx | 0], type: k, alive: true }); lastMon = mx; };
const S = {
flat() { const len = R(260, 480), x0 = x; line(len, 0); if (ch(0.5)) groundRings(x0 + 60, x0 + Math.min(len - 40, 220));
if (ch(0.55 + zone * 0.03)) enemy(x0 + R(150, len - 60)); if (x - lastMon > 1300) monitor(x0 + len / 2 | 0); },
hills() { const n = R(2, 3), x0 = x; for (let i = 0; i < n; i++) curve(R(240, 380), cy(R(-100, 100))); if (ch(0.4)) groundRings(x0 + 40, x - 40, 40); if (ch(0.4)) enemy(x0 + R(100, x - x0 - 100)); },
downhill() { curve(R(420, 620), cy(R(110, 170))); const x0 = x; line(160, 0); if (ch(0.5)) enemy(x0 + 100); },
loop() { const r = R(58, 74); if (y - 2 * r - 60 < 150) curve(300, cy(160));
curve(R(380, 480), cy(R(80, 140))); line(70, 0); lv.pads.push({ x: x - 50, y: y });
const lx = x + 110; line(380, 0); lv.loops.push({ cx: lx, cy: y - r, r });
for (let i = 0; i < 8; i++) { const a = i / 8 * Math.PI * 2; lv.rings.push({ x: lx + Math.sin(a) * (r - 20), y: y - r + Math.cos(a) * (r - 20) }); } },
pit() { line(100, 0); const w = R(70, Math.min(160, 95 + zone * 9)), x0 = x; arcRings(x0 - 20, x0 + w + 20, y - 40, 50);
x += w; y += cy(R(-20, 40)); line(R(180, 260), 0); },
ramp() { lv.pads.push({ x: x + 40, y }); line(100, 0); line(R(110, 140), cy(-R(40, 64))); const gap = R(100, 150); arcRings(x + 20, x + 260, y - 60, 60); x += gap; y += cy(R(40, 90)); line(R(560, 700), 0); },
spring() { const x0 = x; line(R(500, 650), 0); const sx = x0 + 90; lv.springs.push({ x: sx, y: H[sx], dir: 'up', pow: 11.5 });
let px = sx + 60, py = y - 190; for (let i = 0; i < 3; i++) { lv.plats.push({ x: px, y: py, w: 110, x0: px, amp: 0, per: 1, ph: 0 }); groundRingsOn(px + 15, py, 4); px += 170; py = clamp(py + R(-20, 40), 150, y - 80); } },
plats() { const total = R(520, 820), x0 = x, y0 = y; let px = x + R(50, 80), py = y;
while (px < x0 + total - 140) { const w = R(104, 150); py = clamp(py + R(-45, 35), 220, 520); const mv = ch(0.2 + zone * 0.03);
const p = { x: px, y: py, w, x0: px, amp: mv ? R(24, 40) : 0, per: R(180, 260), ph: R(0, 200) }; lv.plats.push(p); groundRingsOn(px + 18, py, Math.floor((w - 20) / 26));
px += w + (mv ? 60 : R(40, 80)); }
x = Math.max(px + 40, x0 + total); y = clamp(py + R(-10, 30), 240, 520);
if (zone < 2) { // first zones: a safe lower path under the platforms, with a ramp back up
const yl = Math.min(Math.max(y0, y) + 100, 580); for (let i = x0; i < x - 260; i++) put(i, yl);
for (let i = 0; i < 260; i++) put(x - 260 + i, yl + (y - yl) * (1 - Math.cos(Math.PI * i / 260)) / 2); }
line(R(220, 300), 0); },
spikes() { const x0 = x, len = R(320, 420); line(len, 0); lv.spikes.push({ x: x0 + 150, y, w: ch(0.5) ? 32 : 48 }); arcRings(x0 + 120, x0 + 230, y - 30, 40); },
step() { line(160, 0); y -= R(32, 46); line(R(220, 320), 0); },
drop() { line(120, 0); y += cy(R(50, 90)); line(R(220, 320), 0); },
enemies() { const x0 = x, len = R(420, 560); line(len, 0); for (let i = 0; i < R(2, 3); i++) enemy(x0 + 120 + i * 120); groundRings(x0 + 60, x0 + len - 60, 50); }
};
function groundRingsOn(x0, py, n) { for (let i = 0; i < n; i++) lv.rings.push({ x: x0 + i * 26, y: py - 22 }); }
const W = { flat: 4, hills: 4, downhill: 2, loop: 2, pit: 2 + zone * 0.3, ramp: 1, spring: 1.5, plats: 1.5 + zone * 0.2, spikes: 1 + zone * 0.3, step: 1.5, drop: 1, enemies: 1.5 + zone * 0.2 };
if (Z.bg === 'clouds') { W.plats *= 3; W.pit *= 2; }
if (Z.bg === 'factory') { W.spikes *= 2; W.step *= 2; }
if (Z.bg === 'city' || Z.bg === 'sea') W.loop *= 1.8;
if (Z.bg === 'peaks') W.downhill *= 2;
const names = Object.keys(W), tot = names.reduce((a, k) => a + W[k], 0);
line(420, 0);
let prev = '';
while (x < LEN) {
if (!checked && x > LEN * 0.5) { checked = true; const x0 = x; line(200, 0); lv.checks.push({ x: x0 + 100, y, on: false }); continue; }
let r = rnd() * tot, k = names[0]; for (const n of names) { r -= W[n]; if (r <= 0) { k = n; break; } }
if (k === prev && (k === 'pit' || k === 'spikes' || k === 'loop')) continue; prev = k;
S[k]();
}
if (!boss) {
line(320, 0); lv.goal = x; line(1000, 0);
lv.len = x; for (let i = 0; i < 400; i++) put(x + i, 60);
} else {
line(260, 0); const cx0 = x; line(140, 0); lv.checks.push({ x: cx0 + 70, y, on: false });
const bx0 = x; line(720, 0); const bx1 = x;
lv.boss = { x0: bx0, x1: bx1, gy: y, x: bx0 + 520, y: y - 100, hp: 8, t: 0, dir: -1, hit: 0, bombs: [], st: 'wait', zone };
lv.len = x; for (let i = 0; i < 400; i++) put(x + i, 60);
}
return lv;
}
// ---------------- state ----------------
let LV = null;
const G = { mode: 'menu', ai: 0, p: null, lives: 3, score: 0, cam: { x: 0, y: 0 }, introT: 0, others: new Map(), mp: false, frame: 0, msg: '', msgT: 0, clearT: 0,
time: 0, startT: 0, countT: 0, fin: [], titleT: 0, god: false, bot: false };
function newPlayer() { return { x: 0, y: 0, vx: 0, vy: 0, gs: 0, a: 0, grounded: true, plat: null, face: 1, ball: false, rolling: false, crouch: false, spinrev: 0, charging: false,
jumping: false, hurtT: 0, invT: 0, shield: false, shoesT: 0, starT: 0, rings: 0, dead: false, deadT: 0, loop: null, loopCD: 0, spring: false, anim: 0, check: null,
done: false, ctrlLock: 0, chain: 0, pj: false, pd: false }; }
function groundAt(x) { const i = x | 0; if (i < 0) return 60; if (i >= LV.H.length) return 60; return LV.H[i]; }
function platX(p) { return p.amp ? p.x0 + Math.sin((G.frame + p.ph) * 2 * Math.PI / p.per) * p.amp : p.x0; }
function floorAt(x, footY, up, down) {
let best = NONE, bp = null; const h = groundAt(x);
if (h !== NONE && h >= footY - up && h <= footY + down) best = h;
for (const p of LV.plats) if (x >= p.x && x <= p.x + p.w && p.y >= footY - up && p.y <= footY + down && p.y < best) { best = p.y; bp = p; }
return best === NONE ? null : { y: best, plat: bp };
}
function angleAt(x) { const h1 = groundAt(x - 6), h2 = groundAt(x + 6); if (h1 === NONE || h2 === NONE || Math.abs(h2 - h1) > 30) return 0; return Math.atan2(h2 - h1, 12); }
const authority = () => !NET.inRoom || NET.id === NET.host;
function startAct(ai) {
G.ai = ai; LV = genAct(ai); chunks.clear(); bgCache = null;
const p = G.p = newPlayer(); p.x = LV.startX; p.y = groundAt(p.x);
G.cam.x = 0; G.cam.y = clamp(p.y - 150, 0, WH - VH); G.mode = 'intro'; G.introT = 150; G.clearT = 0; G.time = 0; G.fin = []; G.msgT = 0;
G.scattered = []; G.parts = []; G.camLock = false; G.frame = 0; G.countT = G.mp ? 240 : 0;
stopMusic(); showTouch(true);
}
function respawn() {
const p = G.p, c = p.check; const np = newPlayer(); np.check = c; np.x = c ? c.x : LV.startX; np.y = groundAt(np.x);
if (LV.boss && c && LV.boss.hp <= 0) np.x = c.x;
G.p = np; G.camLock = false; G.scattered = []; G.cam.x = clamp(np.x - VW * 0.4, 0, LV.len); G.cam.y = clamp(np.y - 150, 0, WH - VH);
playMusic(LV.boss && G.bossOn ? 'boss' : ZONES[LV.zone].music);
}
// ---------------- input ----------------
const keys = {};
addEventListener('keydown', e => { if (e.target && e.target.tagName === 'INPUT') return; keys[e.code] = true;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code) && G.mode !== 'menu') e.preventDefault();
if ((e.code === 'KeyP' || e.code === 'Escape') && (G.mode === 'play' || G.mode === 'pause')) togglePause(); });
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
const TB = { L: $('tbL'), R: $('tbR'), D: $('tbD'), A: $('tbA') }, tPtr = new Map(), tl = $('touch');
function tbAt(x, y) { for (const k in TB) { const r = TB[k].getBoundingClientRect(), pad = 12; if (x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad) return k; } return null; }
function tUpd(e) { if (e.type === 'pointerup' || e.type === 'pointercancel') tPtr.delete(e.pointerId); else tPtr.set(e.pointerId, tbAt(e.clientX, e.clientY));
const on = new Set(tPtr.values()); for (const k in TB) TB[k].classList.toggle('down', on.has(k)); e.preventDefault(); ac(); }
['pointerdown', 'pointermove', 'pointerup', 'pointercancel'].forEach(t => tl.addEventListener(t, tUpd));
function showTouch(on) { const t = on && isTouch; tl.classList.toggle('hide', !t); tl.classList.toggle('on', t); $('pauseBtn').classList.toggle('hide', !on); }
function readInput() {
const k = keys, tp = new Set(tPtr.values());
const inp = { l: k.ArrowLeft || k.KeyA || tp.has('L'), r: k.ArrowRight || k.KeyD || tp.has('R'), d: k.ArrowDown || k.KeyS || tp.has('D'),
a: k.Space || k.KeyZ || k.KeyX || k.ArrowUp || k.KeyW || k.KeyK || tp.has('A') };
const gps = navigator.getGamepads ? navigator.getGamepads() : [];
for (const gp of gps) { if (!gp) continue; const ax = gp.axes[0] || 0, ay = gp.axes[1] || 0, b = i => gp.buttons[i] && gp.buttons[i].pressed;
if (ax < -0.4 || b(14)) inp.l = true; if (ax > 0.4 || b(15)) inp.r = true; if (ay > 0.5 || b(13)) inp.d = true; if (b(0) || b(1) || b(2)) inp.a = true;
if (b(9) && !G.gpStart && (G.mode === 'play' || G.mode === 'pause')) togglePause(); G.gpStart = b(9); }
if (G.bot) return botInput();
if (G.paused) return { l: false, r: false, d: false, a: false };
return inp;
}
// ---------------- player physics ----------------
const PH = { acc: 0.07, dec: 0.5, frc: 0.06, top: 6.2, slp: 0.125, rollUp: 0.078, rollDown: 0.3125, rollFrc: 0.023, rollDec: 0.125, air: 0.11, grv: 0.21875, jmp: 6.6, max: 15 };
function pBox(p) { if (p.loop) { const lp = p.loop, th = p.lth; const fx = lp.cx + lp.r * Math.sin(th), fy = lp.cy + lp.r * Math.cos(th);
const cx = fx + (lp.cx - fx) * 14 / lp.r, cy = fy + (lp.cy - fy) * 14 / lp.r; return { x: cx - 9, y: cy - 12, w: 18, h: 24 }; }
const h = p.ball || p.crouch ? 22 : 30; return { x: p.x - 8, y: p.y - h, w: 16, h }; }
function overlap(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; }
function updPlayer(p, inp) {
if (p.dead) { p.deadT++; if (p.deadT === 1) p.vy = -7; p.vy += 0.25; p.y += p.vy; return; }
if (p.invT > 0) p.invT--; if (p.shoesT > 0) p.shoesT--;
if (p.starT > 0) { p.starT--; if (p.starT === 0) playMusic(G.bossOn ? 'boss' : ZONES[LV.zone].music); }
if (p.loopCD > 0) p.loopCD--;
if (p.done) { inp = { l: false, r: false, d: false, a: false }; }
if (p.ctrlLock > 0) { p.ctrlLock--; inp = { l: false, r: false, d: inp.d, a: inp.a }; }
const top = p.shoesT > 0 ? PH.top * 1.6 : PH.top, acc = p.shoesT > 0 ? PH.acc * 2 : PH.acc, frc = LV.ice ? PH.frc * 0.35 : PH.frc;
const jumpPress = inp.a && !p.pj; p.pj = inp.a;
if (p.loop) return updLoop(p, inp, jumpPress);
if (p.grounded) {
// spin dash
if (p.crouch) {
if (jumpPress) { p.spinrev = Math.min(p.spinrev + 2, 8); p.charging = true; sfx('spin'); }
p.spinrev -= p.spinrev / 32;
if (!inp.d) { p.crouch = false; if (p.charging) { p.gs = p.face * (8 + Math.floor(p.spinrev) / 2); p.rolling = true; p.ball = true; sfx('dash'); } p.charging = false; p.spinrev = 0; }
else { p.gs = 0; return; }
}
if (!p.rolling) {
if (inp.l) { if (p.gs > 0) { p.gs -= PH.dec; if (p.gs < 0) p.gs = -0.5; } else if (p.gs > -top) p.gs = Math.max(p.gs - acc, -top); p.face = -1; }
else if (inp.r) { if (p.gs < 0) { p.gs += PH.dec; if (p.gs > 0) p.gs = 0.5; } else if (p.gs < top) p.gs = Math.min(p.gs + acc, top); p.face = 1; }
else p.gs -= Math.min(Math.abs(p.gs), frc) * Math.sign(p.gs);
p.gs += PH.slp * Math.sin(p.a);
if (inp.d && !p.pd) { if (Math.abs(p.gs) >= 1) { p.rolling = true; p.ball = true; sfx('roll'); } else { p.crouch = true; p.gs = 0; } }
} else {
p.gs -= Math.min(Math.abs(p.gs), PH.rollFrc) * Math.sign(p.gs);
if (inp.l && p.gs > 0) p.gs -= PH.rollDec; if (inp.r && p.gs < 0) p.gs += PH.rollDec;
p.gs += (Math.sign(p.gs) === Math.sign(Math.sin(p.a)) ? PH.rollDown : PH.rollUp) * Math.sin(p.a);
if (Math.abs(p.gs) < 0.5) { p.rolling = false; p.ball = false; }
}
p.pd = inp.d;
p.gs = clamp(p.gs, -PH.max, PH.max);
if (jumpPress && !p.crouch) {
const s = Math.sin(p.a), c = Math.cos(p.a);
p.vx = p.gs * c + PH.jmp * s; p.vy = p.gs * s - PH.jmp; p.grounded = false; p.plat = null; p.ball = true; p.jumping = true; p.rolling = false; sfx('jump'); return;
}
// move along the floor in small steps
const n = Math.max(1, Math.ceil(Math.abs(p.gs) / 4));
if (p.plat) { p.x += p.plat.dx || 0; }
for (let i = 0; i < n; i++) {
const nx = p.x + p.gs / n * Math.cos(p.a);
const gh = groundAt(nx);
const f = floorAt(nx, p.y, 20, 20 + Math.abs(p.gs));
if (gh !== NONE && gh < p.y - 20 && !(f && f.plat)) { p.gs = 0; break; } // wall
if (G.camLock && nx < LV.boss.x0 + 8) { p.gs = 0; p.x = LV.boss.x0 + 8; break; }
if (!f) { p.grounded = false; p.plat = null; p.vx = p.gs * Math.cos(p.a); p.vy = p.gs * Math.sin(p.a); p.x = nx; break; }
p.x = nx; p.y = f.y; p.plat = f.plat; p.a = f.plat ? 0 : angleAt(p.x);
}
// loops
if (p.grounded && !p.plat && p.loopCD <= 0 && p.gs > 3.6) for (const lp of LV.loops) {
if (p.x >= lp.cx && p.x - p.gs <= lp.cx && Math.abs(p.y - (lp.cy + lp.r)) < 8) { p.loop = lp; p.lth = 0; p.x = lp.cx; break; } }
} else {
if (p.hurtT <= 0) {
if (inp.l) { if (p.vx > -top) p.vx = Math.max(p.vx - PH.air, -top); p.face = -1; }
else if (inp.r) { if (p.vx < top) p.vx = Math.min(p.vx + PH.air, top); p.face = 1; }
}
if (p.jumping && !inp.a && p.vy < -4) p.vy = -4;
p.vy = Math.min(p.vy + (p.hurtT > 0 ? 0.19 : PH.grv), 16);
const n = Math.max(1, Math.ceil(Math.max(Math.abs(p.vx), Math.abs(p.vy)) / 4));
for (let i = 0; i < n; i++) {
const nx = p.x + p.vx / n, gh = groundAt(nx);
if (gh !== NONE && gh < p.y - 10) { p.vx = 0; } else p.x = nx;
if (G.camLock && p.x < LV.boss.x0 + 8) { p.x = LV.boss.x0 + 8; p.vx = Math.max(0, p.vx); }
const ny = p.y + p.vy / n;
if (p.vy >= 0) { const f = floorAt(p.x, ny, ny - p.y + 12, 0.5); // floors we crossed (or sank into on a slope) this step
if (f) { land(p, f, inp); break; } }
p.y = ny;
}
}
if (p.y > WH + 40 && !G.god) die(p);
else if (p.y > WH + 40) { p.x = p.lastSafe || LV.startX; p.y = groundAt(p.x); p.vx = p.vy = p.gs = 0; p.grounded = true; p.plat = null; G.falls = (G.falls || 0) + 1; }
if (p.grounded && !p.plat && Math.abs(p.a) < 0.3) p.lastSafe = p.x;
p.anim += p.grounded ? Math.abs(p.gs) * 0.08 + 0.02 : 0.3;
}
function land(p, f, inp) {
p.grounded = true; p.y = f.y; p.plat = f.plat; p.a = f.plat ? 0 : angleAt(p.x);
p.gs = Math.abs(p.a) < 0.4 ? p.vx : p.vx * Math.cos(p.a) + p.vy * Math.sin(p.a);
p.jumping = false; p.spring = false; p.chain = 0;
if (p.hurtT > 0) { p.hurtT = 0; p.gs = 0; }
if (inp.d && Math.abs(p.gs) > 1) { p.rolling = true; p.ball = true; } else { p.rolling = false; p.ball = false; }
}
function updLoop(p, inp, jumpPress) {
const lp = p.loop;
p.gs -= 0.3 * Math.sin(p.lth);
if (inp.r) p.gs += 0.04;
p.lth += p.gs / lp.r;
const th = p.lth, fx = lp.cx + lp.r * Math.sin(th), fy = lp.cy + lp.r * Math.cos(th);
p.x = fx; p.y = fy; p.a = -th;
if ((p.gs < 2 && th > 0.9 && th < 5.4) || jumpPress) { // fall off / jump off
const j = jumpPress ? PH.jmp : 0; // tangent is (cos, -sin); the loop centre is at (-sin, -cos) from the feet
p.vx = p.gs * Math.cos(th) - j * Math.sin(th); p.vy = -p.gs * Math.sin(th) - j * Math.cos(th);
p.loop = null; p.grounded = false; p.ball = true; p.jumping = jumpPress; p.loopCD = 40; p.a = 0; if (jumpPress) sfx('jump'); return; }
if (th >= Math.PI * 2) { p.loop = null; p.x = lp.cx + 1; p.y = lp.cy + lp.r; p.a = 0; p.loopCD = 30; }
p.anim += Math.abs(p.gs) * 0.08;
}
function die(p) { if (p.dead || G.god) return; p.dead = true; p.deadT = 0; p.loop = null; stopMusic(); sfx('die'); }
function hurt(p) {
if (p.invT > 0 || p.starT > 0 || p.dead || p.done || G.god) return;
if (p.shield) { p.shield = false; }
else if (p.rings > 0) { const n = Math.min(p.rings, 24); for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI + Math.PI, sp = i % 2 ? 4 : 2.5;
G.scattered.push({ x: p.x, y: p.y - 16, vx: Math.cos(a) * sp * (i % 4 < 2 ? 1 : -1), vy: Math.sin(a) * sp - 1, t: 0 }); } p.rings = 0; }
else { die(p); return; }
sfx('hurt'); p.loop = null; p.grounded = false; p.plat = null; p.vx = -2 * p.face; p.vy = -4; p.hurtT = 1; p.invT = 120; p.ball = false; p.rolling = false; p.crouch = false;
}
function attacking(p) { return p.ball || p.starT > 0 || (p.loop && true); }
// ---------------- objects ----------------
function updObjects(p) {
const box = pBox(p), atk = attacking(p);
// rings
for (const r of LV.rings) if (!r.taken && Math.abs(r.x - (box.x + box.w / 2)) < 15 && Math.abs(r.y - (box.y + box.h / 2)) < 20) { r.taken = true; addRing(p); }
for (const s of G.scattered) { s.t++; s.vy += 0.12; s.x += s.vx; s.y += s.vy; const g = groundAt(s.x); if (g !== NONE && s.y > g - 6 && s.vy > 0) { s.y = g - 6; s.vy *= -0.75; }
if (s.t > 40 && !p.dead && Math.abs(s.x - (box.x + box.w / 2)) < 15 && Math.abs(s.y - (box.y + box.h / 2)) < 20) { s.t = 999; addRing(p); } }
G.scattered = G.scattered.filter(s => s.t < 280);
if (p.dead || p.done) return;
// enemies
for (const e of LV.ents) {
if (!e.alive) continue;
if (Math.abs(e.x - p.x) > VW) { continue; }
e.t++;
if (e.type === 'crab') { e.x += e.dir * 0.6; const g = groundAt(e.x + e.dir * 10); if (g === NONE || Math.abs(g - e.y) > 12) e.dir = -e.dir;
if (e.x - e.x0 > 70) e.dir = -1; else if (e.x - e.x0 < -70) e.dir = 1; const gy = groundAt(e.x); if (gy !== NONE) e.y = gy; }
else if (e.type === 'bee') { e.x += Math.sin(e.t * 0.02) * 0.9; e.y = e.by + Math.sin(e.t * 0.07) * 10; e.dir = p.x < e.x ? -1 : 1; }
else if (e.type === 'roller') { if (Math.abs(p.x - e.x) < 220) e.dir = p.x < e.x ? -1 : 1; const nx = e.x + e.dir * 2.2, g = groundAt(nx);
if (g !== NONE && Math.abs(g - e.y) < 14) { e.x = nx; e.y = g; } else e.dir = -e.dir; }
const eb = { x: e.x - 11, y: e.y - (e.type === 'bee' ? 10 : 20), w: 22, h: e.type === 'bee' ? 18 : 20 };
if (overlap(box, eb)) {
if (atk) { e.alive = false; sfx('pop'); G.parts.push({ k: 'boom', x: e.x, y: eb.y + 10, t: 0 }); G.parts.push({ k: 'bird', x: e.x, y: eb.y + 10, t: 0, vx: Math.random() < 0.5 ? -1 : 1 });
p.chain++; G.score += [100, 200, 500, 1000][Math.min(3, p.chain - 1)];
if (!p.grounded && !p.loop) { if (p.vy > 0) p.vy = -Math.max(p.vy, 4); else p.vy -= 1; } }
else hurt(p);
}
}
// monitors
for (const m of LV.mons) { if (!m.alive) continue; const mb = { x: m.x - 12, y: m.y - 26, w: 24, h: 26 };
if (overlap(box, mb)) { m.alive = false; sfx('mon'); giveMon(p, m.type); G.parts.push({ k: 'boom', x: m.x, y: m.y - 13, t: 0 }); if (!p.grounded && !p.loop && p.vy > 0) p.vy = -3.5; } }
// springs
for (const s of LV.springs) { const sb = { x: s.x - 12, y: s.y - 12, w: 24, h: 12 };
if (overlap(box, sb) && (p.grounded || p.vy >= 0)) { p.vx = p.grounded ? p.gs : p.vx; p.grounded = false; p.plat = null; p.vy = -s.pow; p.ball = false; p.spring = true; p.jumping = false; p.rolling = false; s.anim = 12; sfx('spring'); } }
// boost pads
for (const d of LV.pads) if (p.grounded && Math.abs(p.x - d.x) < 16 && Math.abs(p.y - d.y) < 8) { if (p.gs < 11) { p.gs = 11; p.face = 1; sfx('pad'); } }
// spikes
for (const s of LV.spikes) { const sb = { x: s.x, y: s.y - 12, w: s.w, h: 12 }; if (overlap(box, sb)) hurt(p); }
// checkpoints
for (const c of LV.checks) if (!c.on && p.x > c.x) { c.on = true; p.check = c; sfx('check'); }
for (const c of LV.checks) if (c.on && (!p.check || p.check.x < c.x) && p.x > c.x) p.check = c;
// goal
if (LV.goal && p.x > LV.goal && !p.done) finish(p);
if (LV.boss) updBoss(p, box, atk);
}
function addRing(p) { p.rings++; G.score += 10; sfx('ring'); if (p.rings % 100 === 0) { G.lives++; sfx('life'); } }
function giveMon(p, type) {
if (type === 'ring') { for (let i = 0; i < 10; i++) addRing(p); }
else if (type === 'shield') p.shield = true;
else if (type === 'shoes') p.shoesT = 900;
else if (type === 'star') { p.starT = 1200; playMusic('star'); }
else if (type === 'life') { G.lives++; sfx('life'); }
G.parts.push({ k: 'icon', type, x: p.x, y: p.y - 40, t: 0 });
}
function finish(p) {
p.done = true; G.finTime = G.time; sfx('sign'); G.clearT = 1; G.signT = 0;
SAVE.un = Math.max(SAVE.un, Math.min(23, G.ai + 1)); SAVE.cur = Math.min(23, G.ai + 1); save();
const tb = G.time < 60 * 45 ? 10000 : G.time < 60 * 60 ? 5000 : G.time < 60 * 90 ? 2000 : G.time < 60 * 120 ? 1000 : 500;
G.bonus = { time: tb, ring: p.rings * 100 }; G.score += tb + p.rings * 100;
if (NET.inRoom) { netSend({ t: 'fin', ai: G.ai, time: G.time, name: myName() }); addFin(NET.id, myName(), G.time); }
}
function updBoss(p, box, atk) {
const b = LV.boss;
if (!G.camLock && p.x > b.x0 + 80 && b.hp > 0) { G.camLock = true; G.bossOn = true; playMusic('boss'); b.st = 'fly'; }
if (b.st === 'wait') return;
b.t++; if (b.hit > 0) b.hit--;
if (b.hp > 0) {
const sp = 1.1 + b.zone * 0.12; b.x += b.dir * sp; if (b.x < b.x0 + 60) b.dir = 1; if (b.x > b.x1 - 60) b.dir = -1;
const swoop = b.zone >= 2 && b.t % 400 > 300; b.y += ((swoop ? b.gy - 58 : b.gy - 100) - b.y) * 0.04;
if (b.t % Math.max(70, 130 - b.zone * 8) === 0) { b.bombs.push({ x: b.x, y: b.y + 16, vy: 0, t: 0 }); }
const bb = { x: b.x - 22, y: b.y - 14, w: 44, h: 30 };
if (overlap(box, bb) && b.hit <= 0 && !p.dead) {
if (atk && !p.grounded) { b.hp--; b.hit = 50; sfx('boss'); G.score += 100; p.vy = -3; p.vx = (p.x < b.x ? -1 : 1) * 2.5; if (b.hp <= 0) { b.st = 'boom'; b.t = 0; G.score += 1000; } }
else hurt(p);
}
} else if (b.st === 'boom') {
if (b.t % 8 === 0) { G.parts.push({ k: 'boom', x: b.x + (Math.random() - 0.5) * 40, y: b.y + (Math.random() - 0.5) * 30, t: 0 }); sfx('boom'); }
if (b.t > 120) { b.st = 'flee'; G.camLock = false; G.bossOn = false; stopMusic(); }
} else if (b.st === 'flee') { b.x += 3; b.y -= 1.5; if (b.t > 200 && !p.done) finish(p); }
for (const bo of b.bombs) { bo.t++; if (!bo.boom) { bo.vy += 0.15; bo.y += bo.vy; if (bo.y >= b.gy - 6) { bo.boom = 1; bo.t = 0; sfx('boom'); } }
const r = bo.boom ? 22 : 7; if (!bo.done && Math.abs(bo.x - (box.x + box.w / 2)) < r + 8 && Math.abs(bo.y - (box.y + box.h / 2)) < r + 12) hurt(p); if (bo.boom && bo.t > 20) bo.done = true; }
b.bombs = b.bombs.filter(x => !x.done);
}
// ---------------- step ----------------
function step() {
const inp = readInput();
if (G.mode === 'menu') { G.titleT++; return; }
if (G.mode === 'pause') return;
if (G.msgT > 0) G.msgT--;
if (G.mode === 'intro') { if (--G.introT <= 0) { G.mode = 'play'; if (!G.mp) playMusic(ZONES[LV.zone].music); } return; }
if (G.mode === 'over') { if (--G.overT <= 0) toMenu(); return; }
if (G.mode !== 'play') return;
G.frame++;
const p = G.p;
if (G.countT > 0) { G.countT--; if (G.countT % 60 === 0 && G.countT > 0) sfx('beep'); if (G.countT === 0) { sfx('go'); playMusic(ZONES[LV.zone].music); } netTick(); return; }
for (const pl of LV.plats) { const nx = platX(pl); pl.dx = nx - pl.x; pl.x = nx; }
updPlayer(p, inp);
updObjects(p);
if (!p.done && !p.dead) G.time++;
for (const q of G.parts) { q.t++; if (q.k === 'bird') { q.x += q.vx * 1.5; q.y -= 1; } if (q.k === 'icon') q.y -= 0.5; }
G.parts = G.parts.filter(q => q.t < (q.k === 'boom' ? 24 : 90));
for (const s of LV.springs) if (s.anim) s.anim--;
for (const o of G.others.values()) if (o.tx !== undefined) { if (Math.abs(o.tx - o.x) > 200) { o.x = o.tx; o.y = o.ty; } else { o.x += (o.tx - o.x) * 0.4; o.y += (o.ty - o.y) * 0.4; } }
// camera
if (!p.dead) {
const look = clamp((p.loop ? p.gs : p.grounded ? p.gs : p.vx) * 12, -60, 60);
let tx = p.x - VW * 0.42 + look * 0.5;
if (G.camLock) tx = clamp(tx, LV.boss.x0, LV.boss.x1 - VW);
G.cam.x += (tx - G.cam.x) * 0.25; G.cam.x = clamp(G.cam.x, 0, LV.len - VW);
const ty = clamp(p.y - 140, 0, WH - VH); G.cam.y += (ty - G.cam.y) * (Math.abs(ty - G.cam.y) > 60 ? 0.3 : 0.12);
}
if (G.time >= 60 * 600 && !p.dead && !p.done) die(p);
if (p.dead && p.deadT > 140) {
if (G.mp) { respawn(); }
else { if (!SAVE.easy) G.lives--; if (G.lives < 0) { G.mode = 'over'; G.overT = 220; stopMusic(); return; } respawn(); }
}
if (G.clearT) { G.clearT++; G.signT++;
if (G.clearT === 90) sfx('clear');
if (!G.mp && G.clearT > 330) { if (G.ai >= 23) { G.win = true; if (G.clearT > 600) toMenu(); return; } startAct(G.ai + 1); return; }
}
if (G.mp && authority() && G.fin.length) { G.finWait = (G.finWait || 0) + 1;
const all = NET.players.every(pl => G.fin.some(f => f.id === pl.id));
if ((all && G.finWait > 240) || G.finWait > 60 * 30) { G.finWait = 0; const next = G.ai >= 23 ? 0 : G.ai + 1; netSend({ t: 'start', ai: next }); startAct(next); return; } }
netTick();
}
function toMenu() {
G.mode = 'menu'; G.paused = false; stopMusic(); showTouch(false); $('pause').classList.add('hide'); if (NET.inRoom) netLeave();
$('menu').classList.remove('hide'); updMenu(); $('bPlay').focus();
}
// ---------------- rendering ----------------
const chunks = new Map(); let bgCache = null;
function hash(n) { n = (n << 13) ^ n; return ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 0x7fffffff; }
function chunk(i) {
let c = chunks.get(i); if (c) return c;
if (chunks.size > 12) chunks.delete(chunks.keys().next().value);
c = document.createElement('canvas'); c.width = 256; c.height = WH; const g = c.getContext('2d'), Z = ZONES[LV.zone];
for (let x = 0; x < 256; x++) { const wx = i * 256 + x, h = groundAt(wx); if (h === NONE || wx < 0) continue; const hi = Math.round(h);
let y = hi + 8; while (y < WH) { const ny = Math.min(WH, (Math.floor(y / 16) + 1) * 16); g.fillStyle = ((wx >> 4) + (y >> 4)) & 1 ? Z.d1 : Z.d2; g.fillRect(x, y, 1, ny - y); y = ny; }
g.fillStyle = Z.top; g.fillRect(x, hi, 1, 6); g.fillStyle = Z.top2; g.fillRect(x, hi + 6, 1, 2);
if (Z.bg === 'hills' && wx % 90 < 3 && hash(wx >> 4) > 0.5) { g.fillStyle = '#ffe040'; g.fillRect(x, hi - 3, 1, 3); }
}
chunks.set(i, c); return c;
}
function drawBG() {
const Z = ZONES[LV.zone], cx = G.cam.x, cy = G.cam.y;
if (!bgCache) { const gr = ctx.createLinearGradient(0, 0, 0, VH); gr.addColorStop(0, Z.sky[0]); gr.addColorStop(1, Z.sky[1]); bgCache = gr; }
ctx.fillStyle = bgCache; ctx.fillRect(0, 0, VW, VH);
const par = (f, sp) => { const o = cx * f; const first = Math.floor(o / sp) - 1; return { o, first, last: first + Math.ceil(VW / sp) + 2 }; };
const by = 150 - cy * 0.15;
if (Z.bg === 'cave' || Z.bg === 'factory') {
const q = par(0.3, 70); for (let i = q.first; i <= q.last; i++) { const x = i * 70 - q.o, h = 30 + hash(i) * 50;
ctx.fillStyle = Z.far; if (Z.bg === 'cave') { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + 18, h); ctx.lineTo(x + 36, 0); ctx.fill(); ctx.fillStyle = Z.far2; ctx.fillRect(x + 16, h - 10, 3, 8); }
else { ctx.fillRect(x, by - 70, 20, 200); ctx.fillStyle = Z.far2; ctx.fillRect(x + 8, by - 60 + (G.frame + i * 20) % 120, 4, 4); ctx.fillStyle = '#3a4048'; ctx.fillRect(x - 20, by - 20 + hash(i + 3) * 40, 70, 6); } }
return;
}
if (Z.bg === 'city') { const q = par(0.25, 46); for (let i = q.first; i <= q.last; i++) { const x = i * 46 - q.o, h = 50 + hash(i) * 90; ctx.fillStyle = Z.far; ctx.fillRect(x, by + 40 - h, 40, h + 200);
for (let wy = 0; wy < h - 10; wy += 10) for (let wx = 4; wx < 36; wx += 9) if (hash(i * 97 + wy * 7 + wx) > 0.55) { ctx.fillStyle = hash(i + wy) > 0.5 ? '#ffe070' : Z.far2; ctx.fillRect(x + wx, by + 44 - h + wy, 4, 5); } } return; }
// clouds
{ const q = par(0.1, 150); ctx.fillStyle = Z.bg === 'volcano' ? 'rgba(80,20,10,.6)' : 'rgba(255,255,255,.85)';
for (let i = q.first; i <= q.last; i++) { const x = i * 150 - q.o + hash(i) * 50, y = 20 + hash(i + 9) * 50 - cy * 0.05; ctx.beginPath(); ctx.ellipse(x, y, 26, 9, 0, 0, 7); ctx.ellipse(x + 18, y - 5, 16, 9, 0, 0, 7); ctx.fill(); } }
if (Z.bg === 'sea') { ctx.fillStyle = Z.far; ctx.fillRect(0, by + 10, VW, VH); ctx.fillStyle = Z.far2; for (let i = 0; i < VW; i += 24) ctx.fillRect((i - cx * 0.3) % VW + (i - cx * 0.3 < 0 ? VW : 0), by + 16 + (i % 48 ? 6 : 14), 12, 2); return; }
if (Z.bg === 'clouds') { const q = par(0.3, 120); ctx.fillStyle = Z.far2; for (let i = q.first; i <= q.last; i++) { const x = i * 120 - q.o; ctx.beginPath(); ctx.ellipse(x, by + 60, 60, 26, 0, 0, 7); ctx.fill(); } return; }
const q = par(0.2, 160);
for (let i = q.first; i <= q.last; i++) { const x = i * 160 - q.o, h = 50 + hash(i + 2) * 60; ctx.fillStyle = Z.far;
if (Z.bg === 'peaks' || Z.bg === 'volcano') { ctx.beginPath(); ctx.moveTo(x - 90, by + 60); ctx.lineTo(x, by + 60 - h - 20); ctx.lineTo(x + 90, by + 60); ctx.fill();
ctx.fillStyle = Z.far2; ctx.beginPath(); ctx.moveTo(x - 18, by + 40 - h); ctx.lineTo(x, by + 40 - h - 20 + 20 - 20); ctx.lineTo(x + 18, by + 40 - h); ctx.fill(); }
else { ctx.beginPath(); ctx.ellipse(x, by + 60, 100, h, 0, Math.PI, 0); ctx.fill(); ctx.fillStyle = Z.far2; ctx.fillRect(x - 60, by + 50, 120, 4); } }
ctx.fillStyle = Z.far; ctx.fillRect(0, by + 60, VW, VH);
}
function drawRunner(x, y, ang, p, ch, alpha) {
const C = charOf(ch || 0); ctx.save(); ctx.globalAlpha = alpha || 1; ctx.translate(Math.round(x), Math.round(y)); ctx.rotate(ang);
const t = p.anim || 0;
if (p.dead) { ctx.rotate(0); }
if (p.ball && !p.dead) {
ctx.translate(0, -11); ctx.rotate(t * (p.face || 1)); ctx.fillStyle = C.body; ctx.beginPath(); ctx.arc(0, 0, 11, 0, 7); ctx.fill();
ctx.strokeStyle = C.dark; ctx.lineWidth = 2; for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.arc(i * 5, 0, 11, -1.2, 1.2); ctx.stroke(); }
ctx.fillStyle = '#fff'; ctx.fillRect(-5, -8, 3, 3); ctx.fillStyle = C.scarf; ctx.fillRect(6, -2, 4, 4);
ctx.restore(); return; }
ctx.scale(p.face || 1, 1);
const crouch = p.crouch ? 6 : 0, run = Math.abs(p.gs || p.vx || 0);
// legs
if (p.grounded !== false && run > 0.3 && !p.crouch) { if (run > 5.5) { ctx.fillStyle = 'rgba(255,140,40,.9)'; ctx.beginPath(); ctx.ellipse(0, -5, 8, 5, 0, 0, 7); ctx.fill(); }
else { const s = Math.sin(t * 2.2) * 5; ctx.fillStyle = '#5a3a20'; ctx.fillRect(-2 + s, -8, 3, 6); ctx.fillRect(-2 - s, -8, 3, 6); ctx.fillStyle = '#ff8a20'; ctx.fillRect(-3 + s, -3, 7, 3); ctx.fillRect(-3 - s, -3, 7, 3); } }
else { ctx.fillStyle = '#5a3a20'; ctx.fillRect(-4, -8, 3, 6); ctx.fillRect(2, -8, 3, 6); ctx.fillStyle = '#ff8a20'; ctx.fillRect(-5, -3, 7, 3); ctx.fillRect(1, -3, 7, 3); }
// shell body with bands
ctx.fillStyle = C.body; ctx.beginPath(); ctx.ellipse(-1, -16 + crouch, 11, 9 - crouch / 3, 0, 0, 7); ctx.fill();
ctx.strokeStyle = C.dark; ctx.lineWidth = 2; for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.moveTo(-1 + i * 5, -24 + crouch); ctx.lineTo(-1 + i * 5, -8); ctx.stroke(); }
// scarf
ctx.fillStyle = C.scarf; const wave = Math.sin(t * 3) * 2; ctx.fillRect(3, -21 + crouch, 6, 3); ctx.beginPath(); ctx.moveTo(4, -21 + crouch); ctx.lineTo(-10 - run, -24 + wave + crouch); ctx.lineTo(-9 - run, -19 + wave + crouch); ctx.lineTo(4, -18 + crouch); ctx.fill();
// head
ctx.fillStyle = '#e8c49a'; ctx.beginPath(); ctx.ellipse(9, -22 + crouch, 6, 5, 0, 0, 7); ctx.fill(); ctx.beginPath(); ctx.moveTo(12, -25 + crouch); ctx.lineTo(19, -20 + crouch); ctx.lineTo(12, -18 + crouch); ctx.fill();
ctx.fillStyle = C.body; ctx.beginPath(); ctx.ellipse(6, -28 + crouch, 2.5, 4, -0.4, 0, 7); ctx.fill();
ctx.fillStyle = '#fff'; ctx.fillRect(9, -25 + crouch, 3, 3); ctx.fillStyle = '#000'; ctx.fillRect(11, -24 + crouch, 1, 2); ctx.fillRect(18, -21 + crouch, 2, 2);
if (p.spring) { ctx.fillStyle = '#e8c49a'; ctx.fillRect(4, -34, 2, 8); ctx.fillRect(-2, -34, 2, 8); }
ctx.restore();
}
function drawPlayerAt(p, ch, alpha, name) {
const cx = G.cam.x, cy = G.cam.y;
if (!alpha && p.invT > 0 && Math.floor(p.invT / 3) % 2) return;
let x = p.x, y = p.y, a = p.a || 0;
if (p.loop && p.lth !== undefined) { const lp = p.loop; x = lp.cx + lp.r * Math.sin(p.lth); y = lp.cy + lp.r * Math.cos(p.lth); a = -p.lth; }
if (!p.grounded && !p.loop) a = 0;
drawRunner(x - cx, y - cy, a, p, ch, alpha);
if (p.shield) { ctx.strokeStyle = 'rgba(120,200,255,.8)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x - cx, y - cy - 14, 17, 0, 7); ctx.stroke(); }
if (p.starT > 0) for (let i = 0; i < 4; i++) { const an = G.frame * 0.2 + i * 1.57; ctx.fillStyle = ['#ff4', '#4ff', '#f4f', '#fff'][i]; ctx.fillRect(x - cx + Math.cos(an) * 16 - 1, y - cy - 14 + Math.sin(an) * 16 - 1, 3, 3); }
if (name) { ctx.font = '8px monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#000'; ctx.fillText(name, x - cx + 1, y - cy - 37); ctx.fillStyle = '#fff'; ctx.fillText(name, x - cx, y - cy - 38); ctx.textAlign = 'left'; }
if (p.charging && p.crouch) { ctx.fillStyle = 'rgba(255,255,255,.7)'; for (let i = 0; i < 3; i++) ctx.fillRect(x - cx - p.face * (12 + Math.random() * 10), y - cy - 4 - Math.random() * 6, 3, 2); }
}
function ringImg(x, y, t) { const w = Math.abs(Math.cos(t * 0.1 + x * 0.01)) * 6 + 1; ctx.strokeStyle = '#ffd020'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(x, y, w, 7, 0, 0, 7); ctx.stroke();
ctx.strokeStyle = '#fff6b0'; ctx.lineWidth = 1; ctx.beginPath(); ctx.ellipse(x - 1, y - 1, Math.max(0.5, w - 2), 5, 0, -2, -0.5); ctx.stroke(); }
function render() {
if (G.mode === 'menu' || !LV) return renderTitle();
const cx = Math.round(G.cam.x), cy = Math.round(G.cam.y), Z = ZONES[LV.zone];
drawBG();
if (LV.lava) { ctx.fillStyle = '#e84a10'; ctx.fillRect(0, WH - 40 - cy, VW, VH); ctx.fillStyle = '#ffb020'; for (let i = 0; i < VW; i += 16) ctx.fillRect(i, WH - 40 - cy + Math.sin((i + cx + G.frame * 2) * 0.1) * 2, 8, 3); }
// loops (behind)
for (const lp of LV.loops) { const x = lp.cx - cx, y = lp.cy - cy; if (x < -120 || x > VW + 120) continue;
ctx.lineWidth = 10; ctx.strokeStyle = Z.d2; ctx.beginPath(); ctx.arc(x, y, lp.r + 5, 0, 7); ctx.stroke(); ctx.lineWidth = 3; ctx.strokeStyle = Z.top; ctx.beginPath(); ctx.arc(x, y, lp.r + 1, 0, 7); ctx.stroke(); }
const c0 = Math.floor(cx / 256), c1 = Math.floor((cx + VW) / 256);
for (let i = c0; i <= c1; i++) ctx.drawImage(chunk(i), i * 256 - cx, -cy);
for (const p of LV.plats) { const x = Math.round(p.x - cx), y = Math.round(p.y - cy); if (x > VW || x + p.w < 0) continue;
ctx.fillStyle = Z.d2; ctx.fillRect(x, y + 6, p.w, 10); ctx.fillStyle = Z.top; ctx.fillRect(x, y, p.w, 6); ctx.fillStyle = Z.top2; ctx.fillRect(x, y + 6, p.w, 2); ctx.fillStyle = Z.d1; for (let i = 4; i < p.w - 4; i += 16) ctx.fillRect(x + i, y + 10, 8, 4); }
for (const s of LV.spikes) { const x = s.x - cx, y = s.y - cy; if (x > VW || x + s.w < 0) continue; ctx.fillStyle = '#d0d0d8'; for (let i = 0; i < s.w; i += 8) { ctx.beginPath(); ctx.moveTo(x + i, y); ctx.lineTo(x + i + 4, y - 12); ctx.lineTo(x + i + 8, y); ctx.fill(); }
ctx.fillStyle = '#808088'; ctx.fillRect(x, y - 2, s.w, 2); }
for (const d of LV.pads) { const x = d.x - cx, y = d.y - cy; if (x < -30 || x > VW + 30) continue; ctx.fillStyle = '#333'; ctx.fillRect(x - 14, y - 3, 28, 3); ctx.fillStyle = G.frame % 20 < 10 ? '#ffe040' : '#ff8020';
for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.moveTo(x - 10 + i * 7, y - 3); ctx.lineTo(x - 6 + i * 7, y - 6); ctx.lineTo(x - 10 + i * 7, y - 9); ctx.lineTo(x - 7 + i * 7, y - 9); ctx.lineTo(x - 3 + i * 7, y - 6); ctx.lineTo(x - 7 + i * 7, y - 3); ctx.fill(); } }
for (const s of LV.springs) { const x = s.x - cx, y = s.y - cy; if (x < -30 || x > VW + 30) continue; const sq = s.anim ? 6 : 0; ctx.fillStyle = '#888'; ctx.fillRect(x - 10, y - 4, 20, 4);
ctx.strokeStyle = '#aaa'; ctx.lineWidth = 2; ctx.beginPath(); for (let i = 0; i < 4; i++) { ctx.moveTo(x - 6, y - 4 - i * 2); ctx.lineTo(x + 6, y - 5 - i * 2); } ctx.stroke(); ctx.fillStyle = s.pow > 12 ? '#e03030' : '#ffd020'; ctx.fillRect(x - 12, y - 14 - (s.anim ? 6 : 0) + sq * 0, 24, 5); }
for (const c of LV.checks) { const x = c.x - cx, y = c.y - cy; if (x < -20 || x > VW + 20) continue; ctx.fillStyle = '#556'; ctx.fillRect(x - 1, y - 34, 3, 34); ctx.fillStyle = c.on ? '#ff4040' : '#4060ff'; ctx.beginPath(); ctx.arc(x + 0.5, y - 37, 4, 0, 7); ctx.fill(); }
if (LV.goal) { const x = LV.goal - cx, y = groundAt(LV.goal) - cy; if (x > -40 && x < VW + 40) { ctx.fillStyle = '#888'; ctx.fillRect(x - 1, y - 40, 3, 40);
const sw = Math.abs(Math.cos((G.signT || 0) * 0.25 * (G.signT < 90 ? 1 : 0))) * 14 + 1; ctx.fillStyle = '#fff'; ctx.fillRect(x - sw, y - 66, sw * 2, 26);
ctx.fillStyle = G.signT > 90 ? CHARS[SAVE.ch].body : '#6a6a6a'; ctx.beginPath(); ctx.arc(x, y - 53, Math.max(1, sw - 4) * 0.8, 0, 7); ctx.fill(); } }
for (const m of LV.mons) { if (!m.alive) continue; const x = m.x - cx, y = m.y - cy; if (x < -30 || x > VW + 30) continue;
ctx.fillStyle = '#ccc'; ctx.fillRect(x - 12, y - 26, 24, 24); ctx.fillStyle = '#333'; ctx.fillRect(x - 9, y - 23, 18, 15); ctx.fillStyle = '#888'; ctx.fillRect(x - 12, y - 3, 24, 3);
drawIcon(m.type, x, y - 15, G.frame % 30 < 26); }
for (const r of LV.rings) { if (r.taken) continue; const x = r.x - cx, y = r.y - cy; if (x < -10 || x > VW + 10 || y < -10 || y > VH + 10) continue; ringImg(x, y, G.frame); }
for (const s of G.scattered) { if (s.t > 200 && s.t % 4 < 2) continue; ringImg(s.x - cx, s.y - cy, G.frame * 2); }
for (const e of LV.ents) { if (!e.alive) continue; const x = e.x - cx, y = e.y - cy; if (x < -30 || x > VW + 30) continue; drawEnemy(e, x, y); }
if (LV.boss && LV.boss.st !== 'wait') drawBoss(LV.boss, cx, cy);
for (const [id, o] of G.others) if (o.ai === G.ai && o.x !== undefined) { const pl = NET.players.find(q => q.id === id); drawPlayerAt(o, o.ch, 0.6, pl ? pl.name : ''); }
drawPlayerAt(G.p, SAVE.ch, 0, G.mp ? myName() : '');
for (const q of G.parts) { const x = q.x - cx, y = q.y - cy;
if (q.k === 'boom') { ctx.fillStyle = q.t % 6 < 3 ? '#fff' : '#ffb020'; ctx.beginPath(); ctx.arc(x, y, 4 + q.t * 0.6, 0, 7); ctx.fill(); }
else if (q.k === 'bird') { ctx.fillStyle = '#4af'; ctx.fillRect(x - 3, y - 2, 6, 4); ctx.fillStyle = '#fff'; ctx.fillRect(x - 5 + (q.t % 10 < 5 ? 0 : 2), y - 4, 3, 2); }
else if (q.k === 'icon') drawIcon(q.type, x, y, true); }
drawHUD();
if (G.mode === 'intro') { const k = Math.min(1, (150 - G.introT) / 20, G.introT / 20);
ctx.fillStyle = '#2050d0'; ctx.fillRect(0, 70, VW * k, 34); ctx.fillStyle = '#ffe040'; ctx.fillRect(VW - VW * k, 104, VW, 18);
ctx.font = 'bold italic 18px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.fillText(Z.name.toUpperCase(), VW / 2, 94);
ctx.font = 'bold 12px "Courier New",monospace'; ctx.fillStyle = '#000'; ctx.fillText(LV.act === 2 ? 'ACT 3 BOSS' : 'ACT ' + (LV.act + 1), VW / 2, 117); ctx.textAlign = 'left'; }
if (G.countT > 0) { ctx.font = 'bold 40px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#000'; const n = Math.ceil(G.countT / 60) - 1;
const t = n > 0 ? String(n) : 'GO!'; ctx.fillText(t, VW / 2 + 2, 112); ctx.fillStyle = '#ffe040'; ctx.fillText(t, VW / 2, 110); ctx.textAlign = 'left'; }
if (G.clearT > 60) { ctx.textAlign = 'center'; ctx.font = 'bold italic 16px "Courier New",monospace'; ctx.fillStyle = '#000'; ctx.fillText(myName().toUpperCase() + ' CLEARED THE ACT!', VW / 2 + 1, 71); ctx.fillStyle = '#fff'; ctx.fillText(myName().toUpperCase() + ' CLEARED THE ACT!', VW / 2, 70);
ctx.font = 'bold 11px "Courier New",monospace'; ctx.fillStyle = '#ffe040';
if (G.bonus && !G.mp) { ctx.fillText('TIME BONUS ' + G.bonus.time, VW / 2, 92); ctx.fillText('RING BONUS ' + G.bonus.ring, VW / 2, 106); }
if (G.mp) G.fin.slice().sort((a, b) => a.time - b.time).forEach((f, i) => { ctx.fillText((i + 1) + '. ' + f.name + ' ' + fmtTime(f.time), VW / 2, 92 + i * 13); });
if (G.win) { ctx.fillStyle = '#fff'; ctx.fillText('YOU BEAT PROFESSOR GEARHEAD! THE END', VW / 2, 150); }
ctx.textAlign = 'left'; }
if (G.mode === 'over') { ctx.fillStyle = 'rgba(0,0,0,.6)'; ctx.fillRect(0, 0, VW, VH); ctx.font = 'bold 20px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.fillText('GAME OVER', VW / 2, 120); ctx.textAlign = 'left'; }
if (G.msgT > 0) { ctx.font = 'bold 12px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#ffe040'; ctx.fillText(G.msg, VW / 2, 150); ctx.textAlign = 'left'; }
}
function drawIcon(type, x, y, on) {
if (!on) return;
if (type === 'ring') { ctx.strokeStyle = '#ffd020'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 4, 0, 7); ctx.stroke(); }
else if (type === 'shield') { ctx.fillStyle = '#4ac0ff'; ctx.beginPath(); ctx.arc(x, y, 5, 0, 7); ctx.fill(); }
else if (type === 'shoes') { ctx.fillStyle = '#ff6020'; ctx.fillRect(x - 5, y - 1, 9, 4); ctx.fillRect(x - 5, y - 4, 4, 3); }
else if (type === 'star') { ctx.fillStyle = '#ffe040'; ctx.beginPath(); for (let i = 0; i < 10; i++) { const r = i % 2 ? 2 : 5.5, a = -Math.PI / 2 + i * Math.PI / 5; ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r); } ctx.fill(); }
else if (type === 'life') { ctx.fillStyle = CHARS[SAVE.ch].body; ctx.beginPath(); ctx.arc(x, y, 5, 0, 7); ctx.fill(); ctx.fillStyle = '#fff'; ctx.fillRect(x + 1, y - 2, 2, 2); }
}
function drawEnemy(e, x, y) {
const t = e.t || 0;
if (e.type === 'crab') { ctx.fillStyle = '#d03030'; ctx.beginPath(); ctx.ellipse(x, y - 10, 11, 7, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#808080';
const cl = Math.sin(t * 0.15) * 3; ctx.fillRect(x - 17, y - 16 + cl, 6, 6); ctx.fillRect(x + 11, y - 16 - cl, 6, 6); ctx.fillStyle = '#555'; for (let i = -1; i <= 1; i += 2) { ctx.fillRect(x + i * 6 - 1, y - 4, 2, 4); ctx.fillRect(x + i * 9 - 1, y - 5, 2, 5); }
ctx.fillStyle = '#fff'; ctx.fillRect(x - 5, y - 15, 3, 3); ctx.fillRect(x + 2, y - 15, 3, 3); ctx.fillStyle = '#000'; ctx.fillRect(x - 4, y - 14, 1, 1); ctx.fillRect(x + 3, y - 14, 1, 1); }
else if (e.type === 'bee') { ctx.fillStyle = '#ffd020'; ctx.beginPath(); ctx.ellipse(x, y, 11, 6, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#222'; ctx.fillRect(x - 4, y - 6, 3, 12); ctx.fillRect(x + 2, y - 6, 3, 12);
ctx.fillStyle = 'rgba(220,240,255,.8)'; ctx.fillRect(x - 6, y - 10 - (t % 6 < 3 ? 2 : 0), 10, 4); ctx.fillStyle = '#888'; ctx.fillRect(x + (e.dir < 0 ? -14 : 10), y - 1, 4, 3); }
else if (e.type === 'roller') { ctx.save(); ctx.translate(x, y - 10); ctx.rotate(e.x * 0.1); ctx.fillStyle = '#7a7a8a'; ctx.beginPath(); ctx.arc(0, 0, 10, 0, 7); ctx.fill(); ctx.strokeStyle = '#4a4a5a'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(10, 0); ctx.moveTo(0, -10); ctx.lineTo(0, 10); ctx.stroke(); ctx.restore();
ctx.fillStyle = '#ff3030'; ctx.fillRect(x + e.dir * 3 - 1, y - 14, 3, 3); }
}
function drawBoss(b, cx, cy) {
const x = b.x - cx, y = b.y - cy;
for (const bo of b.bombs) { const bx = bo.x - cx, by = bo.y - cy; if (bo.boom) { ctx.fillStyle = bo.t % 4 < 2 ? '#fff' : '#ff8020'; ctx.beginPath(); ctx.arc(bx, by, 6 + bo.t, 0, 7); ctx.fill(); }
else { ctx.fillStyle = '#222'; ctx.beginPath(); ctx.arc(bx, by, 6, 0, 7); ctx.fill(); ctx.fillStyle = '#f40'; ctx.fillRect(bx - 1, by - 9, 2, 3); } }
if (b.st === 'flee' && b.t > 200) return;
if (b.hit > 0 && b.hit % 4 < 2) return;
ctx.fillStyle = '#9aa0a8'; ctx.beginPath(); ctx.ellipse(x, y + 4, 24, 12, 0, 0, Math.PI); ctx.fill(); ctx.fillStyle = '#6a7078'; ctx.fillRect(x - 24, y, 48, 5);
ctx.fillStyle = 'rgba(160,220,255,.6)'; ctx.beginPath(); ctx.ellipse(x, y, 15, 14, 0, Math.PI, 0); ctx.fill();
ctx.fillStyle = '#8ad870'; ctx.beginPath(); ctx.arc(x, y - 6, 7, 0, 7); ctx.fill(); ctx.fillStyle = '#333'; ctx.fillRect(x - 6, y - 9, 12, 4); ctx.fillStyle = '#ffd040'; ctx.fillRect(x - 5, y - 8, 4, 2); ctx.fillRect(x + 1, y - 8, 4, 2);
ctx.fillStyle = '#ff6020'; ctx.fillRect(x - 3, y + 14 + (G.frame % 6 < 3 ? 2 : 0), 6, 4);
for (let i = 0; i < 8; i++) { ctx.fillStyle = i < b.hp ? '#ff3a5a' : '#444'; ctx.fillRect(x - 24 + i * 6, y - 24, 5, 4); }
}
function fmtTime(f) { const s = Math.floor(f / 60); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
function drawHUD() {
ctx.font = 'bold 10px "Courier New",monospace'; ctx.textBaseline = 'top';
const sh = (t, x, y, col) => { ctx.fillStyle = '#000'; ctx.fillText(t, x + 1, y + 1); ctx.fillStyle = col || '#fff'; ctx.fillText(t, x, y); };
sh('SCORE', 8, 6, '#ffe040'); sh(String(G.score), 52, 6);
sh('TIME', 8, 18, '#ffe040'); sh(fmtTime(G.time), 52, 18);
const p = G.p; sh('RINGS', 8, 30, p.rings === 0 && G.frame % 30 < 15 ? '#ff3030' : '#ffe040'); sh(String(p.rings), 52, 30);
if (G.mp) { const all = [{ x: p.x, me: 1 }]; for (const o of G.others.values()) if (o.ai === G.ai) all.push({ x: o.x }); all.sort((a, b) => b.x - a.x);
const place = all.findIndex(q => q.me) + 1; sh(['1ST', '2ND', '3RD', '4TH'][place - 1] + ' / ' + all.length, VW - 70, 6, '#ffe040'); }
else sh((SAVE.easy ? '∞' : 'x' + Math.max(0, G.lives)), VW - 40, VH - 16);
ctx.textBaseline = 'alphabetic';
}
function renderTitle() {
if (!LV || LV.ai !== 0 || G.titleLV !== true) { LV = genAct(0); chunks.clear(); bgCache = null; G.titleLV = true; G.p = newPlayer(); }
G.cam.x = (G.titleT * 2) % 6000; G.cam.y = 200; G.frame = G.titleT;
const cx = G.cam.x; drawBG(); const c0 = Math.floor(cx / 256), c1 = Math.floor((cx + VW) / 256);
for (let i = c0; i <= c1; i++) ctx.drawImage(chunk(i), i * 256 - cx, -G.cam.y);
}
// ---------------- networking (relay on CT 127) ----------------
const NET = { ws: null, id: 0, host: 0, room: '', players: [], inRoom: false, sendT: 0 };
function myName() { return BC ? BC.name : (SAVE.name || CHARS[SAVE.ch].name); }
function helloMsg() { return { t: 'hello', game: 'dash', name: myName(), ch: SAVE.ch, col: CHARS[SAVE.ch].body }; }
function netConnect() {
if (NET.ws && NET.ws.readyState <= 1) return;
$('lbStatus').textContent = 'Connecting...';
let ws; try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/mp/ws'); } catch (e) { $('lbStatus').textContent = 'Could not connect.'; return; }
NET.ws = ws;
ws.onopen = () => { ws.send(JSON.stringify(helloMsg())); ws.send(JSON.stringify({ t: 'list' })); $('lbStatus').textContent = 'Connected. Start a new race or join one.'; };
ws.onmessage = ev => { let m; try { m = JSON.parse(ev.data); } catch (e) { return; } onNet(m); };
ws.onclose = () => { const was = NET.inRoom; NET.ws = null; NET.inRoom = false; NET.players = []; G.others.clear();
if (was && G.mode !== 'menu') { G.mp = false; G.msg = 'LOST CONNECTION'; G.msgT = 200; }
$('lbStatus').textContent = 'Not connected. Is the internet working?'; showLobby(); };
}
function netSend(m) { if (NET.ws && NET.ws.readyState === 1) NET.ws.send(JSON.stringify(m)); }
function netLeave() { netSend({ t: 'leave' }); NET.inRoom = false; NET.room = ''; NET.players = []; G.others.clear(); G.mp = false; }
function addFin(id, name, time) { if (!G.fin.some(f => f.id === id)) G.fin.push({ id, name, time }); }
function onNet(m) {
switch (m.t) {
case 'id': NET.id = m.id; return;
case 'rooms': showRooms(m.rooms); return;
case 'err': $('lbStatus').textContent = m.msg; return;
case 'room': { const wasHost = NET.host === NET.id; NET.room = m.code; NET.host = m.host; NET.players = m.players; NET.inRoom = true;
for (const id of [...G.others.keys()]) if (!m.players.find(p => p.id === id)) G.others.delete(id);
if (G.mode === 'menu') showLobby();
else if (authority()) { for (const p of m.players) if (p.id !== NET.id && !G.others.has(p.id)) netSend({ t: 'start', ai: G.ai, to: p.id, late: 1 });
if (!wasHost) { G.msg = 'YOU ARE NOW THE HOST'; G.msgT = 120; } }
return; }
}
if (!NET.inRoom) return;
if (m.t === 'start') { if (m.f !== NET.host) return; G.mp = true; hideMenus(); G.paused = false; if (G.mode === 'menu') G.score = 0; startAct(m.ai); if (m.late) G.countT = 1; return; }
if (!LV || m.ai !== G.ai) return;
if (m.t === 'ps') { let o = G.others.get(m.f); if (!o) { o = {}; G.others.set(m.f, o); }
if (o.x === undefined) { o.x = m.x; o.y = m.y; }
Object.assign(o, { tx: m.x, ty: m.y, a: m.a, ball: !!m.b, face: m.fc, ch: m.c, ai: m.ai, gs: m.g, grounded: !m.air, anim: (o.anim || 0) + Math.abs(m.g) * 0.4, crouch: !!m.cr, loop: null, invT: 0 });
if (m.lp !== undefined && LV.loops[m.lp]) { o.loop = LV.loops[m.lp]; o.lth = m.th; } }
else if (m.t === 'fin') addFin(m.f, m.name, m.time);
}
function netTick() {
if (!NET.inRoom || ++NET.sendT < 4) return; NET.sendT = 0; const p = G.p;
const m = { t: 'ps', ai: G.ai, x: Math.round(p.x), y: Math.round(p.y), a: +(p.a || 0).toFixed(2), b: p.ball ? 1 : 0, fc: p.face, c: [CHARS[SAVE.ch].body, CHARS[SAVE.ch].dark, CHARS[SAVE.ch].scarf], g: +(p.grounded ? p.gs : p.vx).toFixed(1), air: p.grounded ? 0 : 1, cr: p.crouch ? 1 : 0 };
if (p.loop) { m.lp = LV.loops.indexOf(p.loop); m.th = +p.lth.toFixed(2); }
netSend(m);
}
// ---------------- menus ----------------
function hideMenus() { ['menu', 'levels', 'lobby', 'pause'].forEach(id => $(id).classList.add('hide')); }
function updMenu() {
const c = $('chars'); c.innerHTML = '';
CHARS.forEach((ch, i) => { const b = document.createElement('button'); b.className = 'ch'; b.textContent = ch.name; b.style.background = ch.body; b.setAttribute('aria-pressed', i === SAVE.ch);
b.onclick = () => { SAVE.ch = i; if (CHARS.some(x => x.name === SAVE.name)) SAVE.name = ''; save(); updMenu(); }; c.appendChild(b); });
$('bPlay').textContent = SAVE.cur ? 'Play (' + aiName(SAVE.cur) + ')' : 'Play';
$('oEasy').checked = SAVE.easy; $('oSnd').checked = SAVE.snd;
}
$('oEasy').onchange = e => { SAVE.easy = e.target.checked; save(); };
$('oSnd').onchange = e => { SAVE.snd = e.target.checked; save(); if (!SAVE.snd) stopMusic(); };
function newGame(ai) { ac(); G.mp = false; G.lives = 3; G.score = 0; G.win = false; G.titleLV = false; hideMenus(); SAVE.cur = ai; save(); startAct(ai); }
$('bPlay').onclick = () => newGame(SAVE.cur);
$('bLevels').onclick = () => { hideMenus(); const g = $('lvGrid'); g.innerHTML = '';
ZONES.forEach((z, zi) => { const sp = document.createElement('span'); sp.textContent = z.name; g.appendChild(sp);
for (let a = 0; a < 3; a++) { const ai = zi * 3 + a, b = document.createElement('button'); b.textContent = 'Act ' + (a + 1) + (a === 2 ? ' boss' : ''); b.disabled = ai > SAVE.un;
b.setAttribute('aria-label', z.name + ' act ' + (a + 1) + (a === 2 ? ' boss' : '') + (ai > SAVE.un ? ' locked' : '')); b.onclick = () => newGame(ai); g.appendChild(b); } });
$('levels').classList.remove('hide'); };
$('bLvBack').onclick = () => { hideMenus(); $('menu').classList.remove('hide'); };
$('bMP').onclick = () => { ac(); hideMenus(); $('lobby').classList.remove('hide'); $('lbName').value = myName();
$('lbName').disabled = !!BC; $('lbSigned').classList.toggle('hide', !BC); netConnect(); showLobby(); };
$('lbName').onchange = () => { if (BC) return; SAVE.name = $('lbName').value.trim().slice(0, 12); save(); netSend(helloMsg()); };
$('bCreate').onclick = () => { $('lbName').onchange(); netSend({ t: 'create' }); };
$('bLbBack').onclick = () => { if (NET.inRoom) netLeave(); hideMenus(); $('menu').classList.remove('hide'); updMenu(); };
$('bStartMP').onclick = () => { const ai = +$('lbLevel').value; G.mp = true; G.score = 0; G.titleLV = false; netSend({ t: 'started', v: true }); netSend({ t: 'start', ai }); hideMenus(); startAct(ai); };
let roomTimer = null;
function esc(s) { return String(s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
function showLobby() {
if ($('lobby').classList.contains('hide')) { clearInterval(roomTimer); roomTimer = null; return; }
$('lbOut').classList.toggle('hide', NET.inRoom); $('lbIn').classList.toggle('hide', !NET.inRoom);
if (!roomTimer) roomTimer = setInterval(() => { if (!$('lobby').classList.contains('hide') && !NET.inRoom) netSend({ t: 'list' }); }, 2000);
if (NET.inRoom) {
const host = NET.players.find(p => p.id === NET.host), amHost = NET.host === NET.id;
$('lbRoom').innerHTML = '<h2>Race ' + NET.room + '</h2>' + NET.players.map(p => '<div style="color:' + (p.col || '#fff') + '">' + esc(p.name) + (p.id === NET.host ? ' (host)' : '') + (p.id === NET.id ? ' - you' : '') + '</div>').join('');
$('lbHost').classList.toggle('hide', !amHost);
if (amHost) { const sel = $('lbLevel'); if (!sel.options.length || sel.dataset.un != SAVE.un) { sel.innerHTML = ''; sel.dataset.un = SAVE.un;
for (let ai = 0; ai <= SAVE.un; ai++) { const o = document.createElement('option'); o.value = ai; o.textContent = aiName(ai) + (ai % 3 === 2 ? ' (boss)' : ''); sel.appendChild(o); } sel.value = SAVE.cur; } }
$('lbStatus').textContent = amHost ? (NET.players.length < 2 ? 'Waiting for friends to join race ' + NET.room + '...' : 'Pick an act and press Start.') : 'Waiting for ' + (host ? host.name : 'the host') + ' to start.';
}
}
function showRooms(rooms) {
const box = $('rooms'); box.innerHTML = rooms.length ? '' : '<div>No races yet. Start one, then your friends can join it.</div>';
for (const r of rooms) { const d = document.createElement('div'); d.innerHTML = '<span>' + esc(r.host) + "'s race " + r.code + ' (' + r.n + '/4)' + (r.started ? ' racing' : '') + '</span>';
const b = document.createElement('button'); b.className = 'big'; b.style.minWidth = '0'; b.textContent = 'Join'; b.onclick = () => { $('lbName').onchange(); netSend({ t: 'join', code: r.code }); }; d.appendChild(b); box.appendChild(d); }
}
function togglePause() {
if (NET.inRoom) { G.paused = !G.paused; $('pause').classList.toggle('hide', !G.paused); $('bRestart').classList.add('hide'); if (G.paused) $('bResume').focus(); return; }
if (G.mode === 'play') { G.mode = 'pause'; $('pause').classList.remove('hide'); $('bRestart').classList.remove('hide'); stopMusic(); $('bResume').focus(); }
else if (G.mode === 'pause') { G.mode = 'play'; $('pause').classList.add('hide'); playMusic(G.p.starT > 0 ? 'star' : G.bossOn ? 'boss' : ZONES[LV.zone].music); }
}
$('pauseBtn').onclick = () => { if (G.mode === 'play' || G.mode === 'pause') togglePause(); };
$('bResume').onclick = togglePause;
$('bRestart').onclick = () => { $('pause').classList.add('hide'); startAct(G.ai); };
$('bQuit').onclick = () => toMenu();
updMenu();
// ---------------- test helpers ----------------
function simAir(p, hold, brakeAt) { // dry-run a jump; returns landing x, or -1 for a fall
let x = p.x, y = p.y, vx = p.gs * Math.cos(p.a), vy = p.gs * Math.sin(p.a) - PH.jmp, cut = false;
for (let t = 0; t < 200; t++) {
const dir = brakeAt >= 0 && t >= brakeAt ? -1 : 1; vx = dir > 0 ? (vx < PH.top ? Math.min(vx + PH.air, PH.top) : vx) : (vx > -PH.top ? Math.max(vx - PH.air, -PH.top) : vx);
if (t >= hold && !cut && vy < -4) { vy = -4; cut = true; }
vy = Math.min(vy + PH.grv, 16); const nx = x + vx, gh = groundAt(nx); if (gh !== NONE && gh < y - 10) vx = 0; else x = nx;
const ny = y + vy; if (vy >= 0) { const f = floorAt(x, ny, ny - y + 12, 0.5); if (f) return x; } y = ny;
if (y > WH) return -1;
for (const s of LV.spikes) if (x > s.x - 8 && x < s.x + s.w + 8 && y > s.y - 16) return -1;
}
return -1;
}
function botInput() { // level checker: hold right, plan jumps over holes, jump at walls, spikes and robots
const p = G.p, inp = { l: false, r: true, d: false, a: false }; if (!p || !LV) return inp;
if (G.botPlan && !p.grounded) { const bp = G.botPlan; bp.t++; inp.a = bp.t < bp.hold; if (bp.brake >= 0 && bp.t >= bp.brake) { inp.r = false; inp.l = true; } return inp; }
G.botPlan = null;
if (p.grounded && !p.loop) {
let gap = false; for (let dx = 8; dx < 30 + Math.abs(p.gs) * 4; dx += 4) if (!floorAt(p.x + dx, p.y, 20, 40)) { const g = groundAt(p.x + dx); if (g === NONE || g > p.y) gap = true; }
if (gap) { let best = null, bx = p.x + 20; for (const hold of [30, 14]) for (const brake of [-1, 50, 35, 25, 15, 8]) { const lx = simAir(p, hold, brake); if (lx > bx) { bx = lx; best = { hold, brake, t: 0 }; } }
if (best) { G.botPlan = best; inp.a = true; return inp; }
inp.r = false; inp.l = p.gs > 1; return inp; }
}
if (p.grounded && !p.loop) {
let hz = false; const look = 26 + Math.abs(p.gs) * 6;
for (let dx = 8; dx < look; dx += 4) { const g = groundAt(p.x + dx); if (g === NONE) { if (!floorAt(p.x + dx, p.y, 20, 60)) hz = true; } else if (g < p.y - 18) hz = true; }
for (const s of LV.spikes) if (s.x - p.x > 0 && s.x - p.x < look + 10) hz = true;
for (const e of LV.ents) if (e.alive && e.x - p.x > 0 && e.x - p.x < look + 30 && Math.abs(e.y - p.y) < 60) hz = true;
if (LV.boss && G.camLock && LV.boss.hp > 0 && Math.abs(LV.boss.x - p.x) < 40) hz = true;
G.botCD = Math.max(0, (G.botCD || 0) - 1); if (hz && G.botCD === 0) { inp.a = true; G.botCD = 24; }
} else inp.a = !p.grounded && p.jumping;
if (LV.boss && G.camLock && LV.boss.hp > 0) { inp.r = LV.boss.x > p.x + 10; inp.l = LV.boss.x < p.x - 10; }
return inp;
}
window.DASHdebug = { G, get LV() { return LV; }, step, render, genAct, startAct, keys, NET, SAVE };
// ---------------- main loop ----------------
let acc = 0, last = 0;
function frame(ts) { requestAnimationFrame(frame); const dt = Math.min(100, ts - (last || ts)); last = ts; acc += dt; let n = 0;
while (acc >= 1000 / 60 && n < 5 && !G.hold) { step(); acc -= 1000 / 60; n++; } if (n === 5) acc = 0; render(); }
requestAnimationFrame(frame);
</script>
</body>
</html>
EOF
3. Copy the icons
make-icons.ps1 (from the games site post) draws this game's icons into a dash subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):
scp -o BatchMode=yes dash\icon-512.png dash\icon-192.png dash\apple-touch-icon.png dash\favicon-32.png proxmox:/root/nibbles/dash/
4. Push it into CT 125
Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a dash folder:
bash /root/nibbles/05-push-site.sh
5. Check it
Proxmox host shell. Should print 200:
pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/dash/
Then open https://games.503mail.com/dash/ in a browser.
6. Multiplayer
Race Together uses the same games relay as Super Pip (the games-relay service in CT 127 and the /mp/* route on CT 108). If it isn't set up yet, follow step 6 of the Super Pip post. Check it from anywhere; it should print ok rooms=0 players=0:
curl -s https://games.503mail.com/mp/health
Each player's own game runs the whole act, robots included; the relay only passes along everyone's position (15 times a second) and finish times, so a slow connection never slows anyone down.
Updating it later
Edit index.html, paste the new version over /root/nibbles/dash/index.html with the block from step 2, and run step 4 again. No restart is needed; the pages are served with no-cache. A browser that has the page open just needs a reload.
Troubleshooting
The game runs entirely in the browser, so the server's only job is handing out the page.
- The page doesn't load (or shows a 404): that's the server side. Run the check in step 5. If it doesn't print
200, see "Troubleshooting: where the logs are" in the games site post for the Caddy logs on CT 125 (nibbles) and CT 108 (Caddy reverse proxy). - The page loads but the game misbehaves: open the browser's developer console. On a PC press F12, then the Console tab. Red lines are real errors; a 404 for
/favicon.icois harmless (browsers ask for it on their own, and the games use PNG icons instead). - Settings or best scores look wrong: they're kept in the browser's localStorage under 'dashrush'. Clearing the site's data in the browser resets them; nothing is stored on the server.
- Race Together says "Not connected": see the relay troubleshooting in the Super Pip post.
Notes
- How the ground works: each act is a height map, one number per pixel column, built from pieces (flat runs, hills, downhills, loops, pits, ramps, springs, platforms, spikes, steps). The runner moves along the ground in small steps and is tilted to the slope. Gravity along the slope speeds him up or slows him down, as in the original. Loops are circles drawn over the ground; at high enough speed the runner follows the circle, and too slowly he falls off. The ground is drawn into 256-pixel picture strips that are reused, so a Fire tablet doesn't redraw thousands of columns every frame.
- Checked by a robot: a test bot that holds ▶ and plans its jumps by dry-running the real jump physics finished all 24 acts, all 8 bosses included, with damage turned off. It also showed that every loop it reached was run all the way round.
- Made kinder after testing: item boxes now open on any touch, not only when rolling (the bot, like a small kid, got stuck behind them); Professor Gearhead flies lower so a normal jump reaches him; the platform sections got wider platforms and smaller gaps; and in the first two zones there's a safe lower path under the platforms.
- Name: Sonic, Dr. Robotnik and their art and music belong to Sega, so the game has its own runners, robots, zones and tunes. Ideas like loops, rings and springs aren't owned by anyone.