From 70c401b37b6acff378938f49063775b2b456393f Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 26 Sep 2026 20:49:29 +0000 Subject: [PATCH] MESH RUNNER v1: arcade mesh-hopping game with synth SFX and hunter-jammer difficulty curve --- README.md | 44 +++++ index.html | 493 +++++++++++++++++++++++++++++++++++++++++++++++ test_headless.py | 172 +++++++++++++++++ 3 files changed, 709 insertions(+) create mode 100644 README.md create mode 100644 index.html create mode 100644 test_headless.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0c5c87 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# MESH RUNNER — a packet's odyssey + +An arcade game where **you are a data packet** fighting through a contested radio mesh. +Single-file HTML5 canvas game — zero dependencies, zero build step. + +## Play + +Open `index.html` in any browser. That's it. + +## How to play + +- **← ↑ → ↓ / WASD** — hop to the nearest mesh node in that direction +- **Cyan diamond** — data fragment. Grab it: +score, +battery, combo up +- **Gold HUB** (center) — bank there with **combo ×3+** for a big bonus +- **Red triangles** — jammers patrolling the links. They jam nearby nodes (red = deadly), + and touching one torches your battery +- **HUNTERS** (bright red, bigger, from wave 3) — re-target and chase you across the mesh +- **Battery** drains constantly; fragments recharge you. Zero battery → *TRANSMISSION LOST* +- **M** — mute / unmute + +## Difficulty curve + +| Wave | What changes | +|------|-------------| +| 1–2 | Patrol jammers, gentle drain — learn the mesh | +| 3+ | **Hunters** appear — they re-route to the edge nearest you | +| 4+ | Jammer count scales ~0.7/wave (cap 12), patrol speed +1.2%/wave | +| every | Battery drain +0.55/wave, max battery −4/wave (floor 50), wave target grows quadratically | + +## Sound + +All sound effects are synthesized live with the Web Audio API — no asset files. +Pickups rise in pitch with your combo; hunters growl; the game-over sweep is a slow sawtooth fall. +Audio starts on your first keypress (browser autoplay policy). `M` toggles mute. + +## Testing + +- `test_headless.py` — 19 logic checks under py_mini_racer (state machine, scoring, + jam radius, hunter spawn/chase, hub banking, mute, persistence, audio-safety). + Run: `python3 test_headless.py` +- Verified in real headless Chromium: autopilot plays, scores, waves advance, + hunters chase, no JS errors. + +MIT license. Made by Hermes. diff --git a/index.html b/index.html new file mode 100644 index 0000000..72ac7ba --- /dev/null +++ b/index.html @@ -0,0 +1,493 @@ + + + + +MESH RUNNER — a packet's odyssey + + + +
+ + + diff --git a/test_headless.py b/test_headless.py new file mode 100644 index 0000000..6f5e9c1 --- /dev/null +++ b/test_headless.py @@ -0,0 +1,172 @@ +import re, sys +import py_mini_racer as pyr + +html = open('/workspace/mesh-runner/index.html').read() +m = re.search(r'', html, re.S) +assert m, 'no inline script found' +game_js = m.group(1) + +HARNESS = r""" +var ctxCalls = {}; +function makeCtx(){ + return new Proxy({}, { + get(t, k){ + if(k === 'canvas') return {width:960, height:640}; + if(typeof k === 'symbol') return undefined; + if(!(k in t)){ + if(k === 'fillStyle' || k === 'strokeStyle' || k === 'lineWidth' || + k === 'font' || k === 'textAlign' || k === 'shadowColor' || k === 'shadowBlur' || + k === 'globalAlpha'){ + t[k] = ''; + return t[k]; + } + t[k] = function(){ ctxCalls[k] = (ctxCalls[k]||0)+1; return undefined; }; + } + return t[k]; + }, + set(t, k, v){ t[k] = v; return true; } + }); +} +var _els = { cv: { id:'cv', width:960, height:640, getContext: function(){ return makeCtx(); } } }; +var listeners = {}; +var document = { + getElementById: function(id){ return _els[id] || null; }, + addEventListener: function(t,f){ (listeners[t]=listeners[t]||[]).push(f); } +}; +var _vt = 0; // virtual clock: 16.67ms per frame, deterministic +var performance = { now: function(){ return _vt; } }; +var _rafQ = []; +function requestAnimationFrame(cb){ _rafQ.push(cb); return _rafQ.length; } +function frames(n){ + for(var i=0;i= 5, + ev('JSON.stringify(Object.keys(ctxCalls))')) + +# start with Enter +key('keydown', 'Enter') +frames(5) +check('state -> play after Enter', ev('window.__mr.state') == 'play') +check('battery full-ish', ev('window.__mr.battery') > 90, ev('window.__mr.battery')) + +# autopilot soak: autoStep every ~12 frames, top up battery to test scoring not death +for i in range(600): + if i % 12 == 0: + ctx.eval('window.__autoStep();') + if ev('window.__mr.battery') < 40: + ctx.eval('window.window.__mr.battery = 100;') + frames(1) +score1 = ev('window.__mr.score'); wave1 = ev('window.__mr.wave'); cb1 = ev('window.__mr.bestCombo') +check('autopilot scored points', score1 > 0, f'score={score1} wave={wave1} bestCombo={cb1}') +check('autopilot reached wave >= 2', wave1 >= 2, f'wave={wave1}') + +# jam mechanic: park a jammer on an edge touching a node, verify jam rises +ctx.eval(""" + (function(){ + var j = window.__mr.jammers[0]; + j.e = window.__mr.edges[0]; + j.t = 0.5; + })(); +""") +frames(30) +maxjam = ev('Math.max.apply(null,window.__mr.nodes.map(function(n){return n.jam;}))') +check('jammer jams nodes', maxjam > 0.05, f'maxjam={maxjam}') + +# battery death path +ctx.eval('window.__mr.battery = 0.5;') +frames(10) +check('battery death -> over', ev('window.__mr.state') == 'over') +check('best saved to localStorage', ev('window.localStorage.getItem("meshrunner_best")') is not None) + +# retry +key('keydown', 'r') +frames(5) +check('retry -> play', ev('window.__mr.state') == 'play') +check('score reset', ev('window.__mr.score') == 0) + +# hub bank: combo>=3 landing on hub awards bonus +ctx.eval('window.window.__mr.combo = 3; window.__mr.player.node = window.__mr.hub; window.__mr.player.t = 1;') +score_before = ev('window.__mr.score') +frames(3) +score_after = ev('window.__mr.score') +check('hub bank awards combo bonus', score_after > score_before, f'{score_before} -> {score_after}') +check('combo cleared after bank', ev('window.__mr.combo') == 0) + +# no JS errors +errs = ev('JSON.stringify(window.__errs)') +check('no JS runtime errors', errs == '[]', errs) + +# audio safety: no AudioContext in harness -> sfx must no-op without throwing +check('audio-safe without AudioContext', ev('typeof AudioContext') == 'undefined') + +# mute toggle via M +key('keydown', 'm') +check('M mutes', ev('window.__mr.muted') == True) +key('keydown', 'm') +check('M unmutes', ev('window.__mr.muted') == False) + +# hunter mechanic: force wave 3 rebuild, expect at least one hunter jammer +ctx.eval('window.__mr.setWave(3,16);') +n_hunters = ev('window.__mr.jammers.filter(function(j){return j.hunter;}).length') +check('hunters spawn from wave 3', n_hunters >= 1, f'hunters={n_hunters}') + +# hunter re-targets toward player without crashing +ctx.eval(""" + (function(){ + var h = window.__mr.jammers.filter(function(j){return j.hunter;})[0]; + h.x = 100; h.y = 100; + })(); +""") +frames(120) +check('hunter chase runs clean', ev('window.__mr.state') in ('play', 'over')) + +passed = sum(1 for _, ok in results if ok) +print(f'\n{passed}/{len(results)} checks passed') +sys.exit(0 if passed == len(results) else 1)