fix: address all 21 confirmed findings from multi-agent review + live P2P testing

Sim: clamp knockback under tile size (wall tunneling), bomb/ball terminal
velocity, grapple constraint no longer drags players into terrain, bite
window ends on getting hit, shield/boost cleared on death, spawn invuln
preserved through countdown, spike/saw events only on real damage.
Mapgen: spawn footprint validation (was embedding dogs in 58% of seeds),
saw paths rejected if they grind terrain or sweep spawns, towers no longer
hang slabs over pits. Net: heartbeat+watchdog for dead peers, broker-error
tolerance once P2P link is up, join/host reentrancy guards. UI: text-field
keystrokes no longer hit game hotkeys, audio unlocks on mouse, textBaseline
leak, snapshot carries onGround/landT so client dogs animate.
This commit is contained in:
w1n5t0n 2026-06-10 14:47:40 +02:00
parent 2f60136b36
commit 99468cd379
10 changed files with 143 additions and 48 deletions

2
MAP.md
View file

@ -11,7 +11,7 @@ Vanilla-JS canvas game, no build step. ES modules under `js/`, served statically
- `js/characters.js``CHARS` data (stats + visual params) and `updateCharacter()`: per-dog special movement (air dash / charge+pound / grapple constraint / blink / jetpack).
- `js/sim.js`**authoritative, DOM-free sim**: `createGame`, `step(game, inputs)` (60 Hz), combat (`hurt`: damage% → knockback), mining/items/hazards/KO/respawn/match-end, `snapshot`/`applySnapshot` for netplay.
- `js/input.js` — keyboard → bitmasks; slot 0 = WASD+FGH, slot 1 = arrows+KL;.
- `js/net.js` — PeerJS wrapper: `hostRoom`/`joinRoom`; room key = host peer id suffix; signaling via PeerJS cloud, game traffic pure P2P.
- `js/net.js` — PeerJS wrapper: `hostRoom`/`joinRoom`; room key = host peer id suffix; signaling via PeerJS cloud, game traffic pure P2P. Runs a 2s heartbeat + 8s watchdog (WebRTC close events are unreliable); broker `network` errors are ignored once the data channel is up.
- `js/render.js` — canvas: cached seeded background, tiles, procedural dog drawing, particles/ghosts driven by sim events (`fxEvents`), HUD, banners.
- `js/audio.js` — WebAudio-synthesized sfx; `playEvents` maps sim events → sounds.
- `js/main.js` — app state machine: screens, char select, three match drivers (local / host / client) sharing one rAF fixed-timestep loop.

View file

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BARKOUR — dogfights × parkour</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🐕</text></svg>">
<link rel="stylesheet" href="style.css">
<!-- PeerJS: signaling broker only; game traffic is pure P2P WebRTC.
If offline, local 2-player still works. -->

View file

@ -114,9 +114,11 @@ export function updateCharacter(p, c, inp, pressed, game, H) {
const dx = p.x - st.anchor.x, dy = p.y - st.anchor.y;
const d = Math.hypot(dx, dy) || 1;
if (d > st.len) {
p.x = st.anchor.x + dx / d * st.len;
p.y = st.anchor.y + dy / d * st.len;
const nx = dx / d, ny = dy / d;
const cx = st.anchor.x + nx * st.len, cy = st.anchor.y + ny * st.len;
// the radial projection ignores terrain between here and the taut-rope
// position — committing it blindly drags the box through ledge lips
if (H.boxFree(game.map, cx, cy, p.w, p.h)) { p.x = cx; p.y = cy; }
const vr = p.vx * nx + p.vy * ny; // strip outward radial velocity → pendulum
if (vr > 0) { p.vx -= vr * nx; p.vy -= vr * ny; }
}

View file

@ -14,6 +14,7 @@ const PREVENT = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Spa
export function initInput() {
window.addEventListener('keydown', (e) => {
if (e.target && e.target.tagName === 'INPUT') return; // leave text fields alone
if (PREVENT.has(e.code)) e.preventDefault();
if (down.has(e.code)) return;
down.add(e.code);

View file

@ -49,8 +49,10 @@ window.addEventListener('DOMContentLoaded', () => {
startLoop();
});
window.addEventListener('pointerdown', () => initAudio()); // any gesture unlocks audio
window.addEventListener('keydown', (e) => {
initAudio(); // browsers require a gesture before audio
if (e.target && e.target.tagName === 'INPUT') return; // don't eat typing in the room-key field
if (e.code === 'KeyM') {
const m = toggleMute();
$('mute').textContent = m ? '🔇' : '🔊';
@ -204,6 +206,7 @@ function tryStart() {
// ---- networking ----
function startHost() {
if (net) return; // a connection attempt is already in flight
if (!peerAvailable()) { setStatus($('menu-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
mode = 'host';
myPick = null; remotePick = null; peerJoined = false;
@ -226,6 +229,7 @@ function startHost() {
}
function doJoin() {
if (net) return; // a connection attempt is already in flight
if (!peerAvailable()) { setStatus($('join-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
const key = $('join-key').value.toUpperCase().trim();
if (key.length < 4) { setStatus($('join-status'), 'Enter the 5-letter room key.', true); return; }
@ -267,7 +271,7 @@ function onNetData(msg) {
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
break;
case 'tochars':
if (mode === 'client') { myPick = null; remotePick = null; show('s-chars'); refreshCharUI(); }
if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); }
break;
case 'bye':
netClosed('Other player left.');
@ -302,6 +306,7 @@ function doRematch() {
function doChangeDogs() {
sfx('ui');
resultShown = false; // lets the host tick() guard idle the finished match
if (mode === 'local') {
picks = [null, null];
show('s-chars'); refreshCharUI();

View file

@ -38,16 +38,26 @@ export function generateMap(seed) {
}
// --- spawn columns (picked early so hazards can avoid them) ---
// The player box is 18px wide (~1.5 tiles), so a valid spawn column needs
// its neighbours' ground no higher than its own, or the dog spawns embedded.
const spawnCols = [];
const colOk = (x) =>
x >= 1 && x < GW - 1 && !spawnCols.includes(x) &&
hs[x] <= GH - 2 && hs[x - 1] <= GH - 2 && hs[x + 1] <= GH - 2;
const wanted = [0.12, 0.38, 0.62, 0.88].map(f => Math.floor(GW * f) + r.int(-3, 3));
for (let sx of wanted) {
let x = Math.max(2, Math.min(GW - 3, sx));
let tries = 0;
while (hs[x] > GH - 2 && tries++ < 20) x = (x + 1) % (GW - 4) + 2; // skip pits
if (hs[x] <= GH - 2) spawnCols.push(x);
while (!colOk(x) && tries++ < 90) x = (x + 1) % (GW - 4) + 2; // skip pits/steps
if (colOk(x)) spawnCols.push(x);
}
if (spawnCols.length < 2) spawnCols.push(4, GW - 5); // paranoid fallback
const nearSpawn = (x) => spawnCols.some(sx => Math.abs(sx - x) < 5);
// pixel spawn points sit above the highest ground of the 3-column footprint
const spawns = spawnCols.map(x => {
const surf = Math.min(hs[x - 1] ?? GH, hs[x] <= GH ? hs[x] : GH - 4, hs[x + 1] ?? GH);
return { x: x * TILE + TILE / 2, y: surf * TILE - 10 };
});
// --- wall-jump towers rising from the ground ---
const nTowers = r.int(2, 3);
@ -55,8 +65,10 @@ export function generateMap(seed) {
const tx = r.int(8, GW - 9);
if (hs[tx] > GH || nearSpawn(tx)) continue;
const th = r.int(5, 9), tw = r.int(1, 2);
for (let x = tx; x < tx + tw; x++)
for (let y = hs[tx] - th; y < hs[tx]; y++) set(x, y, T.SOLID);
for (let x = tx; x < tx + tw; x++) {
if (hs[x] > GH) continue; // don't hang tower slabs over pit columns
for (let y = hs[x] - th; y < hs[x]; y++) set(x, y, T.SOLID);
}
}
// --- floating islands ---
@ -126,25 +138,44 @@ export function generateMap(seed) {
}
// --- rail saws: hazards moving along fixed paths through open air ---
// A path is rejected if it grinds through terrain (invisible through-wall
// hits) or sweeps within kill radius of a spawn point.
const solidT = (t) => t === T.SOLID || t === T.CRYSTAL || t === T.PAD;
const sawPathOk = (pts, loop) => {
const nSegs = loop ? pts.length : pts.length - 1;
for (let s = 0; s < nSegs; s++) {
const a = pts[s], b = pts[(s + 1) % pts.length];
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
for (let d = 0; d <= len; d += 6) {
const px = a.x + (b.x - a.x) * d / len, py = a.y + (b.y - a.y) * d / len;
for (const [ox, oy] of [[0, 0], [12, 0], [-12, 0], [0, 12], [0, -12]]) {
if (solidT(get(Math.floor((px + ox) / TILE), Math.floor((py + oy) / TILE)))) return false;
}
for (const sp of spawns) if (Math.hypot(sp.x - px, sp.y - py) < 30) return false;
}
}
return true;
};
const saws = [];
const nSaws = r.int(1, 3);
for (let i = 0; i < nSaws; i++) {
for (let t = 0; t < 30; t++) {
const y = (r.int(10, 26)) * TILE;
const x0 = r.int(8, GW - 30) * TILE, x1 = x0 + r.int(12, 20) * TILE;
let pts, loop = false;
if (r.chance(0.5)) {
saws.push({ pts: [{ x: x0, y }, { x: x1, y }], speed: 1.1 + r.f() * 1.1, prog: r.f(), x: x0, y });
pts = [{ x: x0, y }, { x: x1, y }];
} else {
const y2 = y + r.int(4, 8) * TILE; // rectangle loop
saws.push({ pts: [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }], speed: 1.2 + r.f(), prog: r.f(), x: x0, y, loop: true });
pts = [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }];
loop = true;
}
if (!sawPathOk(pts, loop)) continue;
saws.push({ pts, speed: 1.1 + r.f() * 1.1, prog: r.f(), x: x0, y, loop });
break;
}
}
// --- spawn points (pixel coords, centered above surface) ---
const spawns = spawnCols.map(x => {
let surf = hs[x] <= GH ? hs[x] : GH - 4;
return { x: x * TILE + TILE / 2, y: surf * TILE - 10 };
});
return { seed, tiles, hp, spawns, saws, pal: r.int(0, PALETTES.length - 1) };
}

View file

@ -25,27 +25,48 @@ function errMsg(e) {
return 'Connection error: ' + (e && e.message || t || 'unknown');
}
// WebRTC close events are unreliable (a vanished tab often never fires
// 'close'), so both sides run a heartbeat + watchdog over the data channel.
const HB_INTERVAL = 2000, HB_TIMEOUT = 8000;
function wireConn(c, cb, die, who) {
let lastRecv = performance.now();
c.on('data', (d) => {
lastRecv = performance.now();
if (d && d.t === '_hb') return;
cb.data(d);
});
c.on('close', () => die(`${who} disconnected.`));
c.on('error', () => die('Connection lost.'));
const timer = setInterval(() => {
if (c.open) {
try { c.send({ t: '_hb' }); } catch (_) {}
if (performance.now() - lastRecv > HB_TIMEOUT) die(`${who} stopped responding.`);
}
}, HB_INTERVAL);
return () => clearInterval(timer);
}
// cb: { open(key), conn(), data(msg), close(reason) }
export function hostRoom(key, cb) {
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
const peer = new window.Peer(PRE + key);
let conn = null;
let dead = false;
const die = (msg) => { if (!dead) { dead = true; cb.close(msg); } };
let linked = false; // data channel established — broker hiccups no longer matter
let unwire = null;
const die = (msg) => { if (!dead) { dead = true; if (unwire) unwire(); cb.close(msg); } };
peer.on('open', () => cb.open(key));
peer.on('error', (e) => die(errMsg(e)));
peer.on('error', (e) => { if (linked && e && e.type === 'network') return; die(errMsg(e)); });
peer.on('disconnected', () => { try { peer.reconnect(); } catch (_) { /* broker gone; game traffic is p2p anyway */ } });
peer.on('connection', (c) => {
if (conn) { try { c.close(); } catch (_) {} return; } // room is full (2 players)
conn = c;
c.on('open', () => cb.conn());
c.on('data', (d) => cb.data(d));
c.on('close', () => die('Other player disconnected.'));
c.on('error', () => die('Connection lost.'));
c.on('open', () => { linked = true; unwire = wireConn(c, cb, die, 'Other player'); cb.conn(); });
});
return {
send: (o) => { if (conn && conn.open) conn.send(o); },
destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} },
destroy: () => { dead = true; if (unwire) unwire(); try { peer.destroy(); } catch (_) {} },
};
}
@ -53,18 +74,23 @@ export function joinRoom(key, cb) {
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
const peer = new window.Peer();
let dead = false;
const die = (msg) => { if (!dead) { dead = true; cb.close(msg); } };
let linked = false;
let unwire = null;
const die = (msg) => { if (!dead) { dead = true; if (unwire) unwire(); cb.close(msg); } };
const api = {
send: () => {},
destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} },
destroy: () => { dead = true; if (unwire) unwire(); try { peer.destroy(); } catch (_) {} },
};
peer.on('error', (e) => die(errMsg(e)));
peer.on('error', (e) => { if (linked && e && e.type === 'network') return; die(errMsg(e)); });
peer.on('disconnected', () => { try { peer.reconnect(); } catch (_) { /* broker gone; game traffic is p2p anyway */ } });
peer.on('open', () => {
const c = peer.connect(PRE + key.toUpperCase().trim(), { reliable: true });
c.on('open', () => { api.send = (o) => { if (c.open) c.send(o); }; cb.conn(); });
c.on('data', (d) => cb.data(d));
c.on('close', () => die('Host disconnected.'));
c.on('error', () => die('Connection lost.'));
c.on('open', () => {
linked = true;
api.send = (o) => { if (c.open) c.send(o); };
unwire = wireConn(c, cb, die, 'Host');
cb.conn();
});
});
return api;
}

View file

@ -237,6 +237,7 @@ function drawItemIcon(x, y, item, r = 5) {
const ch = { ball: '●', bomb: '✶', shield: '◍', boost: '»', heal: '+' }[item] || '?';
ctx.fillStyle = item === 'bomb' ? '#ff8a65' : '#00000088';
ctx.fillText(ch, x, y + 0.5);
ctx.textBaseline = 'alphabetic'; // don't leak 'middle' into HUD/banner text
}
function drawEnts(game) {

View file

@ -77,7 +77,7 @@ function stepPlayer(g, p, inp, pressed) {
}
const c = CHARS[p.charId], s = c.stats;
if (p.invuln > 0) p.invuln--;
if (p.invuln > 0 && g.freeze <= 0) p.invuln--; // spawn protection shouldn't burn during countdown
if (p.atkCd > 0) p.atkCd--;
if (p.shield > 0) p.shield--;
if (p.boost > 0) p.boost--;
@ -217,16 +217,20 @@ function breakTile(g, tx, ty, drop) {
}
export function hurt(g, v, dmg, nx, ny, base) {
if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0) return;
if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return; }
if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0) return false;
if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return false; }
v.dmg += dmg;
const m = (base + v.dmg * 0.062) / CHARS[v.charId].stats.weight;
v.vx = nx * m;
v.vy = ny * m - 1.4;
// collision is single-pass AABB: velocities >= TILE/tick tunnel through walls
const cap = TILE - 2.5;
v.vx = Math.max(-cap, Math.min(cap, nx * m));
v.vy = Math.max(-cap, Math.min(cap, ny * m - 1.4));
v.hitstun = Math.min(40, 9 + m * 1.6);
v.invuln = 14;
v.st.charge = 0; v.st.anchor = null; v.st.pound = false;
v.atkT = 0; // a launched dog's bite window ends with it
g.events.push({ t: 'hit', x: v.x, y: v.y, m });
return true;
}
function senseTiles(g, p) {
@ -236,10 +240,11 @@ function senseTiles(g, p) {
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
if (tileAt(g.map, tx, ty) === T.SPIKE) {
const cx = tx * TILE + 6;
hurt(g, p, 9, Math.sign(p.x - cx) * 0.4 || 0.2, -1, 5.5);
if (hurt(g, p, 9, Math.sign(p.x - cx) * 0.4 || 0.2, -1, 5.5)) {
g.events.push({ t: 'spike', x: p.x, y: p.y });
}
}
}
// bounce pad: landed on top of a PAD tile
if (p.justLanded) {
const ty = Math.floor((p.y + hh + 2) / TILE);
@ -308,6 +313,7 @@ function stepEnts(g) {
} else if (e.k === 'ball') {
e.age++; e.ttl--;
e.vy += GRAV * 0.45;
if (e.vy > MAXFALL) e.vy = MAXFALL;
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.ground && pvy > 1) e.vy = -pvy * 0.72;
@ -327,6 +333,7 @@ function stepEnts(g) {
} else if (e.k === 'bomb') {
e.age++; e.fuse--;
e.vy += GRAV;
if (e.vy > MAXFALL) e.vy = MAXFALL;
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.ground && pvy > 1) { e.vy = -pvy * 0.45; e.vx *= 0.7; }
@ -378,12 +385,13 @@ function stepSaws(g) {
const d = Math.hypot(dx, dy);
if (d < 16) {
const n = d || 1;
hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5);
if (hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5)) {
g.events.push({ t: 'saw', x: p.x, y: p.y });
}
}
}
}
}
function kill(g, p) {
p.stocks--;
@ -392,7 +400,7 @@ function kill(g, p) {
x: Math.max(20, Math.min(W - 20, p.x)),
y: Math.max(20, Math.min(H - 20, p.y)),
});
p.st = {}; p.item = null; p.ammo = 0;
p.st = {}; p.item = null; p.ammo = 0; p.shield = 0; p.boost = 0;
p.vx = 0; p.vy = 0; p.hitstun = 0; p.atkT = 0; p.dmg = 0;
p.x = -1000; p.y = -1000;
p.respawn = p.stocks > 0 ? 80 : 0;
@ -434,6 +442,7 @@ export function snapshot(g, events) {
x: +p.x.toFixed(1), y: +p.y.toFixed(1),
vx: +p.vx.toFixed(2), vy: +p.vy.toFixed(2),
f: p.face, d: Math.round(p.dmg), s: p.stocks,
og: p.onGround ? 1 : 0, lt: p.landT | 0,
r: p.respawn, iv: p.invuln, hs: p.hitstun, a: p.atkT,
it: p.item, am: p.ammo, sh: p.shield, bo: p.boost,
st: {
@ -456,6 +465,7 @@ export function applySnapshot(g, s) {
if (!p) return;
p.x = sp.x; p.y = sp.y; p.vx = sp.vx; p.vy = sp.vy;
p.face = sp.f; p.dmg = sp.d; p.stocks = sp.s;
p.onGround = !!sp.og; p.landT = sp.lt;
p.respawn = sp.r; p.invuln = sp.iv; p.hitstun = sp.hs; p.atkT = sp.a;
p.item = sp.it; p.ammo = sp.am; p.shield = sp.sh; p.boost = sp.bo;
p.st = {

View file

@ -3,6 +3,7 @@
import { createGame, step, snapshot, applySnapshot } from '../js/sim.js';
import { generateMap, sawPos } from '../js/mapgen.js';
import { mulberry32 } from '../js/rng.js';
import { boxFree, solidAtPx } from '../js/physics.js';
import { GW, GH, T, B } from '../js/consts.js';
let failures = 0;
@ -32,8 +33,22 @@ for (let s = 1; s <= 40; s++) {
const p = sawPos(saw, 0.37);
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) fail(`seed ${seed}: NaN saw pos`);
}
// spawn footprints must be clear of solids (review finding: 58% of seeds embedded players)
for (const sp of map.spawns) {
if (!boxFree(map, sp.x, sp.y, 18, 14)) fail(`seed ${seed}: spawn embedded in terrain at (${sp.x},${sp.y})`);
}
ok('mapgen: 40 seeds, deterministic, sane geometry');
// saw paths must not grind through terrain or sweep spawn points
for (const saw of map.saws) {
for (let f = 0; f < 1; f += 0.02) {
const p = sawPos(saw, f);
if (solidAtPx(map, p.x, p.y)) { fail(`seed ${seed}: saw path inside solid at (${p.x | 0},${p.y | 0})`); break; }
for (const sp of map.spawns) {
if (Math.hypot(sp.x - p.x, sp.y - p.y) < 25) { fail(`seed ${seed}: saw sweeps spawn point`); f = 2; break; }
}
}
}
}
ok('mapgen: 40 seeds, deterministic, sane geometry, clear spawns, clean saw paths');
// --- full-match simulation with chaotic inputs ---
const ALL_BITS = [0, B.L, B.R, B.L | B.JUMP | B.U, B.R | B.JUMP | B.U, B.ATK, B.SPC, B.R | B.SPC,
@ -84,13 +99,16 @@ ok('sim: 12 chaotic matches, all character matchups, finite state throughout');
const g = createGame(12345, [{ charId: 1 }, { charId: 1 }]);
g.freeze = 0;
const p = g.players[0];
const sx = p.x, sy = p.y;
const tx = Math.floor(p.x / 12) + 2, ty = Math.floor(p.y / 12);
g.map.tiles[ty * GW + tx] = T.CRYSTAL;
g.map.hp[ty * GW + tx] = 3;
p.face = 1;
let dropped = false;
for (let i = 0; i < 200 && !dropped; i++) {
const evs = step(g, [i % 30 === 0 ? B.ATK : 0, 0]);
// pin the dog next to the crystal — the bite's forward lunge would otherwise
// shove it off terrain steps and out of reach
p.x = sx; p.y = sy; p.vx = 0; p.vy = 0; p.face = 1;
step(g, [i % 30 === 0 ? B.ATK : 0, 0]);
if (g.ents.some(e => e.k === 'pickup')) dropped = true;
}
if (!dropped) fail('mining a crystal never dropped a pickup');