diff --git a/MAP.md b/MAP.md
index a88b89c..ba18c17 100644
--- a/MAP.md
+++ b/MAP.md
@@ -6,15 +6,17 @@ Vanilla-JS canvas game, no build step. ES modules under `js/`, served statically
- `style.css` — overlay/menu styling; canvas letterboxed to 16:9 via CSS.
- `js/consts.js` — grid (TILE=12, 80×45 → 960×540), tile enum `T`, input bitmask `B`, item ids, physics constants, snapshot rate.
- `js/rng.js` — seeded mulberry32 (`makeRng`); `randomSeed()` is UI-side only.
-- `js/mapgen.js` — `generateMap(seed)`: ground heightmap, pits, towers, islands, one-way platforms, crystals (hp 3), pads, spikes, rail saws, spawns; `PALETTES`; `sawPos()` polyline eval (ping-pong or loop).
+- `js/mapgen.js` — side-view `generateMap(seed)`: ground heightmap, pits, towers, islands, one-way platforms, crystals (hp 3), pads, spikes, rail saws, spawns; `PALETTES`; `sawPos()` polyline eval (ping-pong or loop).
+- `js/mapgen_arena.js` — top-down `generateArena(seed)`: mirrored (left↔right) walls/pits, corner spawns, guaranteed corridors spawn→center, fallback pit pair (pits are the only KO mechanic), mirrored saw pairs.
- `js/physics.js` — center-based AABB vs tile grid: `moveEntity` (per-axis, one-way platform aware), `raycast`, `boxFree`, `standingOnPlat`.
- `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/sim.js` — **authoritative, DOM-free side-view sim**: `createGame`, `step(game, inputs)` (60 Hz), combat (`hurt`: damage% → knockback), mining/items/hazards/KO/respawn/match-end, `snapshot`/`applySnapshot` for netplay. Sets `game.kind='side'`.
+- `js/sim_arena.js` — top-down "Dogpit" sim, **same export surface as sim.js** (main.js swaps modules per match). x/y plane + z hop axis; KOs via pit falls; specials re-imagined per dog (dash cooldown, ram/slam, zip-leash, blink, hover). Sets `game.kind='arena'`.
- `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. 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/render.js` — canvas: cached seeded background, tiles, procedural dog drawing, particles/ghosts driven by sim events (`fxEvents`), HUD, banners. Branches on `game.kind`: side view (`drawTiles`/`drawDog`) vs top-down (`drawTilesArena`/`drawDogTop`, z-height lifts the dog off its shadow).
- `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.
+- `js/main.js` — app state machine: screens, char select (incl. mode toggle: `gameMode` 'side'|'arena', host-authoritative over the wire via `mode`/`start`/`rematch` messages), three match drivers (local / host / client) sharing one rAF fixed-timestep loop; `matchSim` points at the active sim module.
- `test/smoke.mjs` — `npm test`: headless mapgen determinism + chaotic full matches + targeted mining/stocks scenarios.
## Conventions & gotchas
diff --git a/README.md b/README.md
index 8c67052..8fadbde 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,12 @@
wildly different movement physics brawl across procedurally generated arenas
full of traps and mineable powerups. Last dog standing wins.
+Two arena styles, picked at dog select:
+
+- **⛰ Parkour** (side view) — gravity, wall-jumps, one-way platforms, blast-zone KOs.
+- **🐾 Dogpit** (top-down) — 8-way movement on a dota-style mirrored arena;
+ hop (third axis) over saws, spikes and abyss pits; KO by knocking dogs into the void.
+
No build step, no game server, no asset files — everything (art, sound, maps)
is generated procedurally in the browser.
@@ -18,12 +24,13 @@ Any static file server works (ES modules just need http://, not file://).
## Play
-| | Move | Bite | Special | Item |
-|---|---|---|---|---|
-| **P1** | WASD | F | G | H |
-| **P2** | arrows | K | L | ; |
+| | Move | Bite | Special | Item | Hop (top-down) |
+|---|---|---|---|---|---|
+| **P1** | WASD | F | G | H | Space |
+| **P2** | arrows | K | L | ; | , |
-- **↑/W** jumps, **↓** drops through one-way platforms
+- Side view: **↑/W** jumps (Space works too for P1), **↓** drops through one-way platforms
+- Top-down: **Space/,** hops over pits, saw blades, spikes and bites
- **Bite** is also your mining pick — crack open crystals for powerups
- Damage % makes knockback grow (Smash-style); die by being launched past
the arena edges or into the abyss. 3 stocks each.
@@ -39,13 +46,15 @@ inputs and renders snapshots.)
## The dogs
-| Dog | Breed | Movement identity |
-|---|---|---|
-| **Bolt** | Greyhound | Fastest runner, wall-slide/wall-jump, 8-way air dash |
-| **Brick** | Bulldog | Massive knockback resistance, charging ram, ground-pound shockwave |
-| **Lasso** | Lurcher | Leash grapple — anchor, reel, pendulum-swing, jump-release launch |
-| **Wisp** | Saluki | Low gravity, double jump, blink-teleports through walls |
-| **Stubbs** | Corgi | Jetpack (fuel refills on ground), butt-bounce off hard landings |
+| Dog | Breed | Parkour (side) | Dogpit (top-down) |
+|---|---|---|---|
+| **Bolt** | Greyhound | Wall-slide/wall-jump, 8-way air dash | 8-way zoom dash on cooldown |
+| **Brick** | Bulldog | Charging ram, ground-pound shockwave | Same ram; slam down from a hop |
+| **Lasso** | Lurcher | Pendulum leash-swing, jump-release launch | Zip-leash: latch a wall, reel in, slingshot out |
+| **Wisp** | Saluki | Low gravity, double jump, blink through walls | Blink through walls and over pits |
+| **Stubbs** | Corgi | Jetpack, butt-bounce off hard landings | Hover — floats over pits, saws and spikes |
+
+All dogs keep their weight class: Brick barely launches, Wisp flies across the map.
## Arenas
diff --git a/index.html b/index.html
index a7291f1..d14ff3f 100644
--- a/index.html
+++ b/index.html
@@ -28,9 +28,9 @@
Choose your dog
+
+
+
+
+
diff --git a/js/audio.js b/js/audio.js
index 6832a69..070af5b 100644
--- a/js/audio.js
+++ b/js/audio.js
@@ -79,6 +79,7 @@ export function sfx(name) {
case 'count': tone(440, 0.1, 'square', 0.12); break;
case 'over': tone(523, 0.15, 'square', 0.15); tone(659, 0.15, 'square', 0.15); tone(784, 0.3, 'square', 0.15); break;
case 'drop': tone(880, 0.1, 'sine', 0.08, -200); break;
+ case 'fall': tone(420, 0.45, 'sawtooth', 0.2, -380); break;
case 'ui': tone(600, 0.05, 'square', 0.08); break;
}
}
@@ -91,7 +92,7 @@ const EVENT_SFX = {
shield: 'shield', boost: 'boost', heal: 'heal', grapple: 'grapple',
whiff: 'whiff', blink: 'blink', jet: 'jet', boing: 'boing',
charge: 'charge', thud: 'thud', pound: 'pound', ko: 'ko',
- respawn: 'respawn', go: 'go', over: 'over', drop: 'drop',
+ respawn: 'respawn', go: 'go', over: 'over', drop: 'drop', fall: 'fall',
};
export function playEvents(events) {
diff --git a/js/characters.js b/js/characters.js
index 74c6690..245ee07 100644
--- a/js/characters.js
+++ b/js/characters.js
@@ -100,7 +100,7 @@ export function updateCharacter(p, c, inp, pressed, game, H) {
case 'swing': {
if (st.anchor) {
if (pressed & B.SPC) { st.anchor = null; break; }
- if (pressed & B.JUMP) {
+ if (pressed & (B.JUMP | B.HOP)) {
st.anchor = null;
p.vy -= 2.4; p.vx *= 1.15;
H.event({ t: 'jump', x: p.x, y: p.y });
diff --git a/js/consts.js b/js/consts.js
index 0b68335..addd61a 100644
--- a/js/consts.js
+++ b/js/consts.js
@@ -2,11 +2,13 @@
export const TILE = 12, GW = 80, GH = 45;
export const W = GW * TILE, H = GH * TILE; // 960 x 540
-// Tile types
-export const T = { EMPTY: 0, SOLID: 1, PLAT: 2, SPIKE: 3, PAD: 4, CRYSTAL: 5 };
+// Tile types. PAD is the side-view bounce block (solid); PADTOP is the
+// top-down launch pad (walkable floor tile).
+export const T = { EMPTY: 0, SOLID: 1, PLAT: 2, SPIKE: 3, PAD: 4, CRYSTAL: 5, PIT: 6, PADTOP: 7 };
-// Input bitmask
-export const B = { L: 1, R: 2, U: 4, D: 8, JUMP: 16, ATK: 32, SPC: 64, ITM: 128 };
+// Input bitmask. JUMP rides on the up keys (side view); HOP is the dedicated
+// hop key (Space / comma) so the top-down mode can move north without hopping.
+export const B = { L: 1, R: 2, U: 4, D: 8, JUMP: 16, ATK: 32, SPC: 64, ITM: 128, HOP: 256 };
export const ITEMS = ['ball', 'bomb', 'shield', 'boost', 'heal'];
diff --git a/js/input.js b/js/input.js
index 3ba66b9..527a6d9 100644
--- a/js/input.js
+++ b/js/input.js
@@ -3,8 +3,8 @@
import { B } from './consts.js';
const MAPS = [
- { KeyA: B.L, KeyD: B.R, KeyW: B.U | B.JUMP, KeyS: B.D, KeyF: B.ATK, KeyG: B.SPC, KeyH: B.ITM },
- { ArrowLeft: B.L, ArrowRight: B.R, ArrowUp: B.U | B.JUMP, ArrowDown: B.D, KeyK: B.ATK, KeyL: B.SPC, Semicolon: B.ITM },
+ { KeyA: B.L, KeyD: B.R, KeyW: B.U | B.JUMP, KeyS: B.D, KeyF: B.ATK, KeyG: B.SPC, KeyH: B.ITM, Space: B.HOP },
+ { ArrowLeft: B.L, ArrowRight: B.R, ArrowUp: B.U | B.JUMP, ArrowDown: B.D, KeyK: B.ATK, KeyL: B.SPC, Semicolon: B.ITM, Comma: B.HOP },
];
const bits = [0, 0];
diff --git a/js/main.js b/js/main.js
index 230259e..0c98bc0 100644
--- a/js/main.js
+++ b/js/main.js
@@ -2,7 +2,8 @@
import { SNAP_EVERY } from './consts.js';
import { randomSeed } from './rng.js';
import { CHARS } from './characters.js';
-import { createGame, step, snapshot, applySnapshot } from './sim.js';
+import * as simSide from './sim.js';
+import * as simArena from './sim_arena.js';
import { initInput, getBits, getMergedBits } from './input.js';
import { initRender, resetRender, render, fxEvents } from './render.js';
import { initAudio, playEvents, sfx, toggleMute } from './audio.js';
@@ -11,6 +12,9 @@ import { randomKey, hostRoom, joinRoom, peerAvailable } from './net.js';
const $ = (id) => document.getElementById(id);
// ---- state ----
+const SIMS = { side: simSide, arena: simArena }; // the two game modes
+let gameMode = 'side'; // 'side' (parkour) | 'arena' (top-down dogpit)
+let matchSim = simSide; // sim module driving the current game object
let mode = null; // 'local' | 'host' | 'client'
let net = null;
let game = null;
@@ -44,7 +48,8 @@ window.addEventListener('DOMContentLoaded', () => {
wireMenus();
show('s-menu');
// background demo map behind menus
- game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
+ matchSim = simSide;
+ game = simSide.createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
resetRender(game);
startLoop();
});
@@ -80,7 +85,8 @@ function leaveToMenu(msg) {
myPick = null; remotePick = null; peerJoined = false; picks = [null, null];
$('pause-tag').classList.add('hidden');
if (msg) setStatus($('menu-status'), msg);
- game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
+ matchSim = simSide;
+ game = simSide.createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
resetRender(game);
show('s-menu');
refreshCharUI();
@@ -100,6 +106,29 @@ function wireMenus() {
$('btn-rematch').onclick = () => doRematch();
$('btn-newdogs').onclick = () => doChangeDogs();
$('btn-result-menu').onclick = () => leaveToMenu();
+ $('mode-side').onclick = () => setGameMode('side');
+ $('mode-arena').onclick = () => setGameMode('arena');
+}
+
+function setGameMode(m) {
+ if (mode === 'client') return; // host picks the arena style
+ if (gameMode === m) return;
+ sfx('ui');
+ gameMode = m;
+ refreshModeUI();
+ if (mode === 'host' && net) net.send({ t: 'mode', m });
+}
+
+function refreshModeUI() {
+ $('mode-side').classList.toggle('sel', gameMode === 'side');
+ $('mode-arena').classList.toggle('sel', gameMode === 'arena');
+ const locked = mode === 'client';
+ $('mode-side').disabled = locked;
+ $('mode-arena').disabled = locked;
+ $('mode-hint').textContent =
+ locked ? 'The host picks the arena style.'
+ : gameMode === 'side' ? 'Side-view platforming: wall-jumps, blast zones, gravity.'
+ : 'Top-down dogpit: 8-way movement, hop (Space / ,) over pits and saws — knock dogs into the void!';
}
// ---- char select ----
@@ -157,6 +186,7 @@ function pickChar(id) {
}
function refreshCharUI() {
+ refreshModeUI();
for (const c of CHARS) {
const tags = $(`card-${c.id}`)?.querySelector('.card-tags');
if (!tags) continue;
@@ -195,12 +225,12 @@ function refreshCharUI() {
function tryStart() {
if (mode === 'local') {
if (picks[0] === null || picks[1] === null) return;
- startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]);
+ startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }], gameMode);
} else if (mode === 'host') {
if (myPick === null || remotePick === null) return;
const seed = randomSeed();
- net.send({ t: 'start', seed, chars: [myPick, remotePick] });
- startMatch(seed, [{ charId: myPick }, { charId: remotePick }]);
+ net.send({ t: 'start', seed, chars: [myPick, remotePick], mode: gameMode });
+ startMatch(seed, [{ charId: myPick }, { charId: remotePick }], gameMode);
}
}
@@ -254,21 +284,24 @@ function onNetData(msg) {
remotePick = msg.c;
refreshCharUI();
break;
+ case 'mode': // client: host flipped the arena style
+ if (mode === 'client') { gameMode = msg.m === 'arena' ? 'arena' : 'side'; refreshModeUI(); }
+ break;
case 'start': // client: host started the match
- if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
+ if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }], msg.mode || 'side');
break;
case 'i': // host: remote inputs
remoteBits = msg.b | 0;
break;
case 's': // client: snapshot
if (mode === 'client' && game) {
- const evs = applySnapshot(game, msg.s);
+ const evs = matchSim.applySnapshot(game, msg.s);
fxEvents(evs, game);
playEvents(evs);
}
break;
case 'rematch':
- if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
+ if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }], msg.mode || 'side');
break;
case 'tochars':
if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); }
@@ -280,8 +313,10 @@ function onNetData(msg) {
}
// ---- match lifecycle ----
-function startMatch(seed, defs) {
- game = createGame(seed, defs);
+function startMatch(seed, defs, modeKey = 'side') {
+ gameMode = modeKey;
+ matchSim = SIMS[modeKey] || simSide;
+ game = matchSim.createGame(seed, defs);
resetRender(game);
pendingEvs = [];
remoteBits = 0;
@@ -296,11 +331,11 @@ function startMatch(seed, defs) {
function doRematch() {
sfx('ui');
if (mode === 'local') {
- startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]);
+ startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }], gameMode);
} else if (mode === 'host') {
const seed = randomSeed();
- net.send({ t: 'rematch', seed, chars: [myPick, remotePick] });
- startMatch(seed, [{ charId: myPick }, { charId: remotePick }]);
+ net.send({ t: 'rematch', seed, chars: [myPick, remotePick], mode: gameMode });
+ startMatch(seed, [{ charId: myPick }, { charId: remotePick }], gameMode);
}
}
@@ -355,18 +390,18 @@ function tick() {
// local match, or attract-mode demo behind menus (no inputs)
const i0 = inGame ? getBits(0) : 0;
const i1 = inGame ? getBits(1) : 0;
- const evs = step(game, [i0, i1]);
+ const evs = matchSim.step(game, [i0, i1]);
if (inGame) { fxEvents(evs, game); playEvents(evs); }
if (inGame && game.over && game.overT > 140 && !resultShown) showResult();
} else if (mode === 'host') {
if (!inGame && !resultShown) return; // still in lobby/charselect
- const evs = step(game, [getMergedBits(), remoteBits]);
+ const evs = matchSim.step(game, [getMergedBits(), remoteBits]);
pendingEvs.push(...evs);
fxEvents(evs, game);
playEvents(evs);
if (game.tick % SNAP_EVERY === 0 && net) {
- net.send({ t: 's', s: snapshot(game, pendingEvs) });
+ net.send({ t: 's', s: matchSim.snapshot(game, pendingEvs) });
pendingEvs = [];
}
if (game.over && game.overT > 140 && !resultShown) showResult();
diff --git a/js/mapgen_arena.js b/js/mapgen_arena.js
new file mode 100644
index 0000000..0db6628
--- /dev/null
+++ b/js/mapgen_arena.js
@@ -0,0 +1,156 @@
+// Top-down "Dogpit" arena generation. Mirrored left↔right (dota-style fairness):
+// border walls, wall blobs, abyss pits, a cleared center circle, guaranteed
+// corridors spawn→center, crystals/spikes/launch pads, patrolling saws.
+import { TILE, GW, GH, W, T } from './consts.js';
+import { makeRng } from './rng.js';
+
+export function generateArena(seed) {
+ const r = makeRng(seed);
+ const tiles = new Uint8Array(GW * GH);
+ const hp = {};
+ const set = (x, y, v) => { if (x >= 0 && x < GW && y >= 0 && y < GH) tiles[y * GW + x] = v; };
+ const get = (x, y) => (x < 0 || x >= GW || y < 0 || y >= GH) ? T.SOLID : tiles[y * GW + x];
+ const HALF = GW >> 1;
+
+ // --- border walls (2 thick — nothing leaves the pit) ---
+ for (let x = 0; x < GW; x++) { set(x, 0, T.SOLID); set(x, 1, T.SOLID); set(x, GH - 1, T.SOLID); set(x, GH - 2, T.SOLID); }
+ for (let y = 0; y < GH; y++) { set(0, y, T.SOLID); set(1, y, T.SOLID); set(GW - 1, y, T.SOLID); set(GW - 2, y, T.SOLID); }
+
+ // --- wall blobs on the left half ---
+ const nWalls = r.int(8, 12);
+ for (let i = 0; i < nWalls; i++) {
+ const w = r.int(2, 6), h = r.int(2, 5);
+ const x = r.int(3, HALF - 2 - w), y = r.int(3, GH - 4 - h);
+ for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) set(xx, yy, T.SOLID);
+ }
+
+ // --- abyss pits (the kill mechanic: knock dogs in) ---
+ const wantPits = r.int(2, 4);
+ let pitBlobs = 0, pitTries = 0;
+ while (pitBlobs < wantPits && pitTries++ < 150) {
+ const w = r.int(2, 4), h = r.int(2, 4);
+ const x = r.int(4, HALF - 3 - w), y = r.int(4, GH - 6 - h);
+ let clear = true;
+ for (let yy = y - 1; yy < y + h + 1 && clear; yy++)
+ for (let xx = x - 1; xx < x + w + 1; xx++) if (get(xx, yy) === T.SOLID) { clear = false; break; }
+ if (!clear) continue;
+ for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) set(xx, yy, T.PIT);
+ pitBlobs++;
+ }
+
+ // --- mirror left → right ---
+ for (let y = 0; y < GH; y++) for (let x = 0; x < HALF; x++) tiles[y * GW + (GW - 1 - x)] = tiles[y * GW + x];
+
+ // --- cleared center circle (the contested middle) ---
+ const ccx = GW / 2 - 0.5, ccy = GH / 2 - 0.5;
+ for (let y = 2; y < GH - 2; y++) for (let x = 2; x < GW - 2; x++) {
+ if (Math.hypot(x - ccx, y - ccy) < 7) set(x, y, T.EMPTY);
+ }
+
+ // --- spawns in the four corners, 3x3 cleared ---
+ const spawnT = [[5, 5], [GW - 6, 5], [5, GH - 6], [GW - 6, GH - 6]];
+ for (const [sx, sy] of spawnT)
+ for (let y = sy - 1; y <= sy + 1; y++) for (let x = sx - 1; x <= sx + 1; x++) set(x, y, T.EMPTY);
+ const nearSpawn = (x, y) => spawnT.some(([sx, sy]) => Math.abs(sx - x) < 5 && Math.abs(sy - y) < 5);
+
+ // --- guaranteed L-corridors spawn→center (2 wide) for connectivity ---
+ const carve = (x, y) => { if (x > 1 && x < GW - 2 && y > 1 && y < GH - 2) set(x, y, T.EMPTY); };
+ const mx = GW >> 1, my = GH >> 1;
+ for (const [sx, sy] of spawnT) {
+ for (let x = Math.min(sx, mx); x <= Math.max(sx, mx); x++) { carve(x, sy); carve(x, sy + 1); }
+ for (let y = Math.min(sy, my); y <= Math.max(sy, my); y++) { carve(mx, y); carve(mx + 1, y); }
+ }
+
+ // pits are the only KO mechanic — if blob placement + corridor carving left
+ // too few, stamp a guaranteed mirrored pair clear of corridors and spawns
+ let pitTiles = 0;
+ for (const t of tiles) if (t === T.PIT) pitTiles++;
+ if (pitTiles < 6) {
+ for (let y = 12; y <= 13; y++) for (let x = HALF - 12; x <= HALF - 10; x++) {
+ set(x, y, T.PIT);
+ set(GW - 1 - x, y, T.PIT);
+ }
+ }
+
+ // --- decorations, placed on the left and mirrored for fairness ---
+ const mirrorSet = (x, y, v, crystal) => {
+ set(x, y, v);
+ set(GW - 1 - x, y, v);
+ if (crystal) { hp[y * GW + x] = 3; hp[y * GW + (GW - 1 - x)] = 3; }
+ };
+ const floorAt = (x, y) => get(x, y) === T.EMPTY;
+ const wallAdjacent = (x, y) =>
+ get(x + 1, y) === T.SOLID || get(x - 1, y) === T.SOLID || get(x, y + 1) === T.SOLID || get(x, y - 1) === T.SOLID;
+
+ // crystals hug wall faces
+ let placed = 0, tries = 0;
+ while (placed < 7 && tries++ < 300) {
+ const x = r.int(3, HALF - 2), y = r.int(3, GH - 4);
+ if (!floorAt(x, y) || !wallAdjacent(x, y) || nearSpawn(x, y)) continue;
+ if (!floorAt(GW - 1 - x, y)) continue; // keep the mirror twin valid too
+ mirrorSet(x, y, T.CRYSTAL, true);
+ placed++;
+ }
+
+ // spike patches on open floor
+ let spikes = 0; tries = 0;
+ while (spikes < r.int(2, 4) && tries++ < 200) {
+ const x = r.int(4, HALF - 3), y = r.int(4, GH - 5);
+ if (!floorAt(x, y) || !floorAt(x + 1, y) || nearSpawn(x, y)) continue;
+ mirrorSet(x, y, T.SPIKE); mirrorSet(x + 1, y, T.SPIKE);
+ spikes++;
+ }
+
+ // launch pads (walkable; fling you into a high hop over pits)
+ let pads = 0; tries = 0;
+ while (pads < r.int(2, 3) && tries++ < 200) {
+ const x = r.int(4, HALF - 3), y = r.int(4, GH - 5);
+ if (!floorAt(x, y) || nearSpawn(x, y)) continue;
+ mirrorSet(x, y, T.PADTOP);
+ pads++;
+ }
+
+ // --- spawn pixel coords ---
+ const spawns = spawnT.map(([x, y]) => ({ x: x * TILE + TILE / 2, y: y * TILE + TILE / 2 }));
+
+ // --- patrolling saws, mirrored pairs, paths clear of walls/pits/spawns ---
+ const blocked = (t) => t === T.SOLID || t === T.CRYSTAL || t === T.PIT;
+ 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 (blocked(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, 2);
+ for (let i = 0; i < nSaws; i++) {
+ for (let t = 0; t < 30; t++) {
+ const y = r.int(5, GH - 8) * TILE;
+ const x0 = r.int(4, HALF - 14) * TILE, x1 = x0 + r.int(8, 12) * TILE;
+ let pts, loop = false;
+ if (r.chance(0.5)) {
+ pts = [{ x: x0, y }, { x: x1, y }];
+ } else {
+ const y2 = Math.min((GH - 4) * TILE, y + r.int(4, 8) * TILE);
+ pts = [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }];
+ loop = true;
+ }
+ if (!sawPathOk(pts, loop)) continue;
+ const speed = 1.1 + r.f() * 1.1, prog = r.f();
+ saws.push({ pts, speed, prog, x: pts[0].x, y: pts[0].y, loop });
+ saws.push({ pts: pts.map(pt => ({ x: W - pt.x, y: pt.y })), speed, prog, x: W - pts[0].x, y: pts[0].y, loop });
+ break;
+ }
+ }
+
+ return { seed, tiles, hp, spawns, saws, pal: r.int(0, 3) };
+}
diff --git a/js/render.js b/js/render.js
index 83374b5..f87f027 100644
--- a/js/render.js
+++ b/js/render.js
@@ -21,13 +21,13 @@ export function initRender(canvas) {
export function resetRender(game) {
particles = []; ghosts = []; banners = []; shake = 0;
- buildBackground(game.map);
+ buildBackground(game.map, game.kind);
}
function pal(game) { return PALETTES[game.map.pal % PALETTES.length]; }
// --- static background, cached to an offscreen canvas ---
-function buildBackground(map) {
+function buildBackground(map, kind) {
bgSeed = map.seed;
bgCanvas = document.createElement('canvas');
bgCanvas.width = W; bgCanvas.height = H;
@@ -35,6 +35,22 @@ function buildBackground(map) {
const p = PALETTES[map.pal % PALETTES.length];
const r = mulberry32((map.seed ^ 0xBA9D) >>> 0);
+ if (kind === 'arena') { // top-down: tinted floor with mottled patches + vignette
+ b.fillStyle = p.hill;
+ b.fillRect(0, 0, W, H);
+ for (let i = 0; i < 260; i++) {
+ const x = r() * W, y = r() * H, s = 6 + r() * 22;
+ b.fillStyle = r() < 0.5 ? 'rgba(255,255,255,0.025)' : 'rgba(0,0,0,0.05)';
+ b.fillRect(x, y, s, s);
+ }
+ const vg = b.createRadialGradient(W / 2, H / 2, H * 0.45, W / 2, H / 2, H * 0.95);
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
+ vg.addColorStop(1, 'rgba(0,0,0,0.45)');
+ b.fillStyle = vg;
+ b.fillRect(0, 0, W, H);
+ return;
+ }
+
const grad = b.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, p.sky[0]); grad.addColorStop(1, p.sky[1]);
b.fillStyle = grad;
@@ -118,6 +134,7 @@ export function fxEvents(events, game) {
case 'shield': burst(ev.x, ev.y, 10, '#80d8ff', 2, 20, 0); break;
case 'boost': burst(ev.x, ev.y, 10, '#ffd740', 2, 20, 0); break;
case 'drop': banners.push({ text: 'Care package!', ttl: 70, y: 60 }); break;
+ case 'fall': burst(ev.x, ev.y, 14, '#263238', 2.2, 26, -0.04); shake = Math.max(shake, 3); break;
case 'go': banners.push({ text: 'GO!', ttl: 45, y: H * 0.4, big: true }); break;
}
}
@@ -127,7 +144,8 @@ export function fxEvents(events, game) {
export function render(game, opts = {}) {
frame++;
const p = pal(game);
- if (!bgCanvas || bgSeed !== game.map.seed) buildBackground(game.map);
+ const arena = game.kind === 'arena';
+ if (!bgCanvas || bgSeed !== game.map.seed) buildBackground(game.map, game.kind);
ctx.save();
ctx.clearRect(0, 0, W, H);
@@ -137,11 +155,11 @@ export function render(game, opts = {}) {
} else shake = 0;
ctx.drawImage(bgCanvas, 0, 0);
- drawTiles(game, p);
+ if (arena) drawTilesArena(game, p); else drawTiles(game, p);
drawSaws(game);
drawEnts(game);
drawGhosts();
- for (const pl of game.players) drawDog(game, pl, opts);
+ for (const pl of game.players) (arena ? drawDogTop : drawDog)(game, pl, opts);
drawParticles();
drawHud(game);
drawBanners(game);
@@ -205,6 +223,75 @@ function drawTiles(game, p) {
}
}
+// top-down arena tiles: walls with drop shadows, void pits, caltrops, pads
+function drawTilesArena(game, p) {
+ const tiles = game.map.tiles;
+ // shadow pass so walls read as raised
+ ctx.fillStyle = 'rgba(0,0,0,0.3)';
+ for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
+ const t = tiles[y * GW + x];
+ if (t === T.SOLID || t === T.CRYSTAL) ctx.fillRect(x * TILE + 3, y * TILE + 4, TILE, TILE);
+ }
+ for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
+ const t = tiles[y * GW + x];
+ if (t === T.EMPTY) continue;
+ const px = x * TILE, py = y * TILE;
+ if (t === T.SOLID) {
+ ctx.fillStyle = p.solid;
+ ctx.fillRect(px, py, TILE, TILE);
+ if (y === 0 || tiles[(y - 1) * GW + x] !== T.SOLID) {
+ ctx.fillStyle = p.top;
+ ctx.fillRect(px, py, TILE, 3);
+ }
+ } else if (t === T.PIT) {
+ ctx.fillStyle = '#04060a';
+ ctx.fillRect(px, py, TILE, TILE);
+ if (y > 0 && tiles[(y - 1) * GW + x] !== T.PIT) { // rim on the near edge
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
+ ctx.fillRect(px, py, TILE, 4);
+ }
+ } else if (t === T.SPIKE) {
+ ctx.fillStyle = p.spike;
+ for (const [ox, oy] of [[3, 3], [9, 5], [5, 9]]) {
+ ctx.beginPath();
+ ctx.moveTo(px + ox - 2.5, py + oy + 2);
+ ctx.lineTo(px + ox, py + oy - 3);
+ ctx.lineTo(px + ox + 2.5, py + oy + 2);
+ ctx.fill();
+ }
+ } else if (t === T.PADTOP) {
+ const pulse = 0.5 + 0.5 * Math.sin(frame * 0.12 + x + y);
+ ctx.strokeStyle = p.pad;
+ ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.arc(px + 6, py + 6, 4 + pulse * 1.5, 0, 7); ctx.stroke();
+ ctx.fillStyle = p.pad;
+ ctx.beginPath();
+ ctx.moveTo(px + 3.5, py + 7.5); ctx.lineTo(px + 6, py + 3.5); ctx.lineTo(px + 8.5, py + 7.5);
+ ctx.fill();
+ } else if (t === T.CRYSTAL) {
+ const hp = game.map.hp[y * GW + x] ?? 3;
+ ctx.fillStyle = p.crystal;
+ ctx.beginPath();
+ ctx.moveTo(px + TILE / 2, py);
+ ctx.lineTo(px + TILE, py + TILE / 2);
+ ctx.lineTo(px + TILE / 2, py + TILE);
+ ctx.lineTo(px, py + TILE / 2);
+ ctx.fill();
+ const tw = 0.5 + 0.5 * Math.sin(frame * 0.1 + x * 3 + y);
+ ctx.fillStyle = `rgba(255,255,255,${0.3 + tw * 0.4})`;
+ ctx.fillRect(px + 4, py + 3, 2, 2);
+ if (hp < 3) {
+ ctx.strokeStyle = 'rgba(0,0,0,0.6)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(px + 3, py + 4); ctx.lineTo(px + 7, py + 8);
+ if (hp < 2) { ctx.moveTo(px + 9, py + 3); ctx.lineTo(px + 5, py + 9); }
+ ctx.stroke();
+ }
+ }
+ }
+}
+
function drawSaws(game) {
for (const s of game.map.saws) {
if (s.x === undefined) continue;
@@ -449,6 +536,150 @@ function drawDog(game, pl, opts) {
ctx.fillText(`${dmg}%`, x + 13, y - 18.5);
}
+// top-down dog: shadow at ground level, body lifted by hop height, rotated to
+// the facing angle. Same per-character visual params as the side view.
+function drawDogTop(game, pl, opts) {
+ if (pl.stocks <= 0 || pl.respawn > 0) return;
+ const c = CHARS[pl.charId];
+
+ if (opts.smooth) {
+ if (pl._dx === undefined || Math.abs(pl._dx - pl.x) > 80 || Math.abs(pl._dy - pl.y) > 80) { pl._dx = pl.x; pl._dy = pl.y; }
+ pl._dx += (pl.x - pl._dx) * 0.5;
+ pl._dy += (pl.y - pl._dy) * 0.5;
+ } else { pl._dx = pl.x; pl._dy = pl.y; }
+ const x = pl._dx, y = pl._dy;
+ const z = pl.z || 0;
+ const falling = pl.falling || 0;
+
+ if (Math.hypot(pl.vx, pl.vy) > 5.5 && frame % 2 === 0) {
+ ghosts.push({ x, y, ttl: 10, c: c.color, kind: 'blob' });
+ }
+
+ // leash
+ const an = pl.st && pl.st.anchor;
+ if (an && !falling) {
+ ctx.strokeStyle = c.accent;
+ ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.moveTo(x, y - z * 1.8); ctx.lineTo(an.x, an.y); ctx.stroke();
+ ctx.fillStyle = c.color;
+ ctx.beginPath(); ctx.arc(an.x, an.y, 3, 0, 7); ctx.fill();
+ }
+
+ // shadow stays on the floor
+ if (!falling) {
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
+ ctx.beginPath();
+ ctx.ellipse(x, y + 3, Math.max(3, 10 - z * 0.8), Math.max(2, 5 - z * 0.4), 0, 0, 7);
+ ctx.fill();
+ }
+ // hover flame between shadow and dog
+ if (pl.st && (pl.st.hover || pl.st.jet) && !falling) {
+ ctx.fillStyle = '#ffab40';
+ ctx.beginPath();
+ ctx.ellipse(x, y + 1 - z * 0.9, 3, 5 + Math.random() * 2, 0, 0, 7);
+ ctx.fill();
+ }
+
+ ctx.save();
+ ctx.translate(x, y - z * 1.8);
+ if (pl.invuln > 0 && (frame >> 2) % 2 === 0) ctx.globalAlpha = 0.45;
+ if (falling) { // spiral down the pit
+ const f = falling / 36;
+ ctx.globalAlpha = f;
+ ctx.scale(f, f);
+ ctx.rotate((36 - falling) * 0.35);
+ }
+ ctx.rotate(pl.ang !== undefined ? pl.ang : (pl.face < 0 ? Math.PI : 0));
+ if (pl.hitstun > 2) ctx.rotate(Math.sin(frame * 0.8) * 0.3);
+
+ const bodyL = 10 * c.body, bodyW = 6.5 * c.body;
+ const moving = Math.hypot(pl.vx, pl.vy) > 0.6 && z <= 0;
+ const phase = (x + y) * 0.45;
+
+ // tail (behind, wags sideways)
+ const wag = Math.sin(frame * 0.3) * 3;
+ ctx.strokeStyle = c.color;
+ ctx.lineWidth = c.tail === 'fluff' || c.tail === 'plume' ? 4 : 2;
+ ctx.lineCap = 'round';
+ const tl = c.tail === 'stub' ? 3 : c.tail === 'whip' ? 10 : 7;
+ ctx.beginPath();
+ ctx.moveTo(-bodyL + 1, 0);
+ ctx.quadraticCurveTo(-bodyL - tl * 0.6, wag * 0.5, -bodyL - tl, wag);
+ ctx.stroke();
+
+ // paws poking out the sides
+ ctx.fillStyle = c.accent;
+ for (let i = 0; i < 4; i++) {
+ const lx = -bodyL * 0.55 + (i >> 1) * bodyL * 0.9;
+ const ly = (i % 2 ? 1 : -1) * (bodyW + 1);
+ const swing = moving ? Math.sin(phase + i * 1.7) * 2.5 : 0;
+ ctx.beginPath(); ctx.arc(lx + swing, ly, 1.8, 0, 7); ctx.fill();
+ }
+
+ // jetpack on the back (corgi)
+ if (c.key === 'rocket') {
+ ctx.fillStyle = '#90a4ae';
+ ctx.fillRect(-bodyL * 0.5 - 2, -3, 5, 6);
+ }
+
+ // body + head + snout
+ ctx.fillStyle = c.color;
+ ctx.beginPath(); ctx.ellipse(0, 0, bodyL, bodyW, 0, 0, 7); ctx.fill();
+ if (pl.st && pl.st.charge) { ctx.strokeStyle = '#ff7043'; ctx.lineWidth = 2; ctx.stroke(); }
+ const hx = bodyL * 0.85, hr = 5 * c.body;
+ ctx.beginPath(); ctx.arc(hx, 0, hr, 0, 7); ctx.fill();
+ ctx.beginPath(); ctx.ellipse(hx + hr * 0.8, 0, hr * 0.65 * c.snout, hr * 0.45, 0, 0, 7); ctx.fill();
+ ctx.fillStyle = c.accent;
+ ctx.beginPath(); ctx.arc(hx + hr * 0.8 + hr * 0.65 * c.snout, 0, 1.5, 0, 7); ctx.fill();
+
+ // ears either side of the head
+ ctx.fillStyle = c.accent;
+ for (const side of [-1, 1]) {
+ if (c.ears === 'silk') {
+ ctx.beginPath(); ctx.ellipse(hx - 2, side * (hr + 1), 4, 2, side * 0.4, 0, 7); ctx.fill();
+ } else if (c.ears === 'nub') {
+ ctx.beginPath(); ctx.arc(hx - 1, side * hr, 1.8, 0, 7); ctx.fill();
+ } else {
+ ctx.beginPath();
+ ctx.moveTo(hx - 3, side * (hr - 1));
+ ctx.lineTo(hx - 1, side * (hr + 4));
+ ctx.lineTo(hx + 2, side * (hr - 1));
+ ctx.fill();
+ }
+ }
+
+ // bite arc
+ if (pl.atkT > 0) {
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(hx + hr * c.snout + 4, 0, 5, -0.7, 0.7);
+ ctx.stroke();
+ }
+
+ ctx.restore();
+
+ if (falling) return; // no bubble/tag while disappearing into the void
+
+ if (pl.shield > 0) {
+ ctx.strokeStyle = `rgba(128,216,255,${0.4 + 0.3 * Math.sin(frame * 0.2)})`;
+ ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.arc(x, y - z * 1.8, 17, 0, 7); ctx.stroke();
+ }
+ if (pl.boost > 0 && frame % 3 === 0) spawn(x, y + 2, (Math.random() - 0.5) * 2, 0.5, 12, '#ffd740', 1.5, 0);
+
+ ctx.font = 'bold 9px monospace';
+ ctx.textAlign = 'center';
+ const dmg = Math.round(pl.dmg);
+ const heat = Math.min(1, dmg / 120);
+ ctx.fillStyle = '#00000088';
+ ctx.fillRect(x - 22, y - z * 1.8 - 27, 44, 11);
+ ctx.fillStyle = c.color;
+ ctx.fillText(pl.name, x - 6, y - z * 1.8 - 18.5);
+ ctx.fillStyle = `rgb(255,${Math.round(255 - heat * 200)},${Math.round(255 - heat * 255)})`;
+ ctx.fillText(`${dmg}%`, x + 13, y - z * 1.8 - 18.5);
+}
+
function drawParticles() {
for (const pt of particles) {
pt.ttl--;
diff --git a/js/sim.js b/js/sim.js
index 0496106..6d6de39 100644
--- a/js/sim.js
+++ b/js/sim.js
@@ -11,7 +11,7 @@ const PW = 18, PH = 14;
export function createGame(seed, defs) {
const map = generateMap(seed);
const g = {
- seed, tick: 0, map, players: [], ents: [], nextEnt: 1,
+ kind: 'side', seed, tick: 0, map, players: [], ents: [], nextEnt: 1,
rng: makeRng((seed ^ 0xC0FFEE) >>> 0),
events: [], over: null, overT: 0, freeze: 170, dropTimer: 550,
};
@@ -121,7 +121,7 @@ function stepPlayer(g, p, inp, pressed) {
// --- jumping ---
if (!stunned) {
if ((pressed & B.D) && p.onGround && standingOnPlat(g.map, p)) p.drop = 10;
- if (pressed & B.JUMP) {
+ if (pressed & (B.JUMP | B.HOP)) {
if (p.onGround || p.coyote > 0) {
p.vy = -s.jump * Math.sqrt(bm);
p.coyote = 0;
diff --git a/js/sim_arena.js b/js/sim_arena.js
new file mode 100644
index 0000000..d118ef8
--- /dev/null
+++ b/js/sim_arena.js
@@ -0,0 +1,601 @@
+// Top-down "Dogpit" arena simulation. Same authoritative/DOM-free contract and
+// export surface as sim.js — main.js picks the module per match.
+//
+// The plane is x/y; z is hop height (a third axis for dodging ground hazards
+// and crossing pits). KOs come from being knocked into pits, not blast zones.
+import { TILE, GW, GH, W, H, T, B, STOCKS, ITEMS } from './consts.js';
+import { makeRng } from './rng.js';
+import { generateArena } from './mapgen_arena.js';
+import { sawPos } from './mapgen.js';
+import { moveEntity, tileAt, raycast, boxFree } from './physics.js';
+import { CHARS } from './characters.js';
+
+const PW = 18, PH = 14;
+const ZGRAV = 0.34;
+const VCAP = TILE - 2.5; // collision is single-pass AABB; stay under tile size
+
+export function createGame(seed, defs) {
+ const map = generateArena(seed);
+ const g = {
+ kind: 'arena', seed, tick: 0, map, players: [], ents: [], nextEnt: 1,
+ rng: makeRng((seed ^ 0xD06600D) >>> 0),
+ events: [], over: null, overT: 0, freeze: 170, dropTimer: 550,
+ };
+ defs.forEach((d, i) => g.players.push(makePlayer(d, i, map)));
+ return g;
+}
+
+function makePlayer(d, i, map) {
+ const sp = map.spawns[i % map.spawns.length];
+ return {
+ idx: i, charId: d.charId, name: d.name || CHARS[d.charId].name,
+ x: sp.x, y: sp.y, vx: 0, vy: 0, z: 0, vz: 0, ang: i % 2 ? Math.PI : 0,
+ w: PW, h: PH, face: i % 2 ? -1 : 1, falling: 0,
+ onGround: true, landT: 0,
+ dmg: 0, stocks: STOCKS, respawn: 0, invuln: 90,
+ atkCd: 0, atkT: 0, hitMask: 0, minedSet: {},
+ hitstun: 0, item: null, ammo: 0, shield: 0, boost: 0,
+ prev: 0, st: {}, wallHit: 0,
+ };
+}
+
+const alive = (p) => p.stocks > 0 && p.respawn <= 0 && p.falling <= 0;
+
+export function step(g, inputs) {
+ g.tick++;
+ g.events.length = 0;
+ if (g.freeze > 0) {
+ g.freeze--;
+ if (g.freeze === 0) g.events.push({ t: 'go' });
+ }
+ const frozen = g.freeze > 0 || !!g.over;
+
+ for (const p of g.players) {
+ const inp = frozen ? 0 : (inputs[p.idx] || 0);
+ stepPlayer(g, p, inp, inp & ~p.prev);
+ p.prev = inp;
+ }
+ stepEnts(g);
+ stepSaws(g);
+
+ if (!g.over && --g.dropTimer <= 0) {
+ g.dropTimer = 520 + g.rng.int(0, 300);
+ // care package lands on a random clear floor tile
+ for (let t = 0; t < 40; t++) {
+ const tx = g.rng.int(3, GW - 4), ty = g.rng.int(3, GH - 4);
+ if (tileAt(g.map, tx, ty) === T.EMPTY) {
+ spawnPickup(g, tx * TILE + 6, ty * TILE + 6, g.rng.pick(ITEMS));
+ g.events.push({ t: 'drop' });
+ break;
+ }
+ }
+ }
+
+ checkEnd(g);
+ return g.events;
+}
+
+function stepPlayer(g, p, inp, pressed) {
+ if (p.stocks <= 0) return;
+ if (p.respawn > 0) {
+ p.respawn--;
+ if (p.respawn === 0) respawn(g, p);
+ return;
+ }
+ // mid-fall into a pit: no control, shrink, then KO
+ if (p.falling > 0) {
+ p.falling--;
+ p.vx *= 0.88; p.vy *= 0.88;
+ p.x += p.vx; p.y += p.vy;
+ if (p.falling === 0) kill(g, p);
+ return;
+ }
+
+ const c = CHARS[p.charId], s = c.stats, st = p.st;
+ if (p.invuln > 0 && g.freeze <= 0) p.invuln--;
+ if (p.atkCd > 0) p.atkCd--;
+ if (p.shield > 0) p.shield--;
+ if (p.boost > 0) p.boost--;
+ if (p.landT > 0) p.landT--;
+
+ const bm = p.boost > 0 ? 1.45 : 1;
+ const stunned = p.hitstun > 0;
+ if (stunned) p.hitstun--;
+
+ // --- 8-way movement ---
+ const dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0);
+ const dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0);
+ if (!stunned && !(st.charge > 0) && !st.anchor) {
+ if (dx || dy) {
+ const n = Math.hypot(dx, dy), ux = dx / n, uy = dy / n;
+ const acc = s.accel * (p.z > 0 ? 0.55 : 1) * bm;
+ p.vx += ux * acc;
+ p.vy += uy * acc;
+ const cap = s.run * bm, spd = Math.hypot(p.vx, p.vy);
+ if (spd > cap) { // let dash overspeed decay instead of hard-clamping
+ const k = Math.max(cap, spd - s.accel * 2) / spd;
+ p.vx *= k; p.vy *= k;
+ }
+ p.ang = Math.atan2(uy, ux);
+ if (dx) p.face = dx > 0 ? 1 : -1;
+ } else if (p.z <= 0) {
+ p.vx *= s.fric; p.vy *= s.fric;
+ if (Math.hypot(p.vx, p.vy) < 0.1) { p.vx = 0; p.vy = 0; }
+ } else {
+ p.vx *= 0.99; p.vy *= 0.99;
+ }
+ } else if (stunned && p.z <= 0) {
+ p.vx *= 0.9; p.vy *= 0.9;
+ }
+
+ // --- hop ---
+ if (!stunned && (pressed & B.HOP) && p.z <= 0) {
+ p.vz = 3.0;
+ g.events.push({ t: 'jump', x: p.x, y: p.y });
+ }
+
+ // --- character specials ---
+ updateCharArena(g, p, c, inp, pressed);
+
+ // --- z physics ---
+ const wasAir = p.z > 0;
+ p.z += p.vz;
+ if (!st.hover) p.vz -= ZGRAV;
+ let justLanded = false;
+ if (p.z <= 0) {
+ if (wasAir) {
+ justLanded = true;
+ p.landT = 6;
+ if (-p.vz > 2) g.events.push({ t: 'land', x: p.x, y: p.y + 5, v: 4 });
+ }
+ p.z = 0;
+ if (p.vz < 0) p.vz = 0;
+ }
+ p.onGround = p.z <= 0;
+
+ if (justLanded && st.pound) {
+ st.pound = false;
+ g.events.push({ t: 'pound', x: p.x, y: p.y });
+ for (const o of g.players) {
+ if (o === p || !alive(o) || o.z > 6) continue;
+ const ox = o.x - p.x, oy = o.y - p.y, d = Math.hypot(ox, oy);
+ if (d < 55) { const n = d || 1; hurt(g, o, 9, ox / n, oy / n, 6); }
+ }
+ }
+
+ // --- integrate & wall-collide (y-axis hits count as walls here too) ---
+ const res = moveEntity(p, g.map, {});
+ p.wallHit = res.wall || ((res.ground || res.ceil) ? 2 : 0);
+
+ // --- ground tile sensors ---
+ if (p.z <= 0) {
+ const t = tileAt(g.map, Math.floor(p.x / TILE), Math.floor(p.y / TILE));
+ if (t === T.PIT) {
+ p.falling = 36;
+ p.atkT = 0; p.st.charge = 0; p.st.anchor = null;
+ g.events.push({ t: 'fall', x: p.x, y: p.y });
+ return;
+ }
+ if (t === T.PADTOP && p.vz <= 0) {
+ p.vz = 4.6;
+ g.events.push({ t: 'pad', x: p.x, y: p.y });
+ }
+ // spikes under any part of the footprint
+ const hw = p.w / 2, hh = p.h / 2;
+ const x0 = Math.floor((p.x - hw) / TILE), x1 = Math.floor((p.x + hw) / TILE);
+ const y0 = Math.floor((p.y - hh) / TILE), y1 = Math.floor((p.y + hh) / TILE);
+ for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
+ if (tileAt(g.map, tx, ty) === T.SPIKE) {
+ const sx = tx * TILE + 6, sy = ty * TILE + 6;
+ const n = Math.hypot(p.x - sx, p.y - sy) || 1;
+ if (hurt(g, p, 9, (p.x - sx) / n, (p.y - sy) / n, 5.5)) {
+ g.events.push({ t: 'spike', x: p.x, y: p.y });
+ }
+ }
+ }
+ }
+
+ // --- attack ---
+ if (!stunned && (pressed & B.ATK) && p.atkCd <= 0) {
+ p.atkCd = 24; p.atkT = 7;
+ p.hitMask = 0; p.minedSet = {};
+ p.vx += Math.cos(p.ang) * 1.4;
+ p.vy += Math.sin(p.ang) * 1.4;
+ g.events.push({ t: 'bark', x: p.x, y: p.y, f: p.face });
+ }
+ if (p.atkT > 0) { p.atkT--; doAttack(g, p); }
+
+ if (!stunned && (pressed & B.ITM) && p.item) useItem(g, p);
+}
+
+function updateCharArena(g, p, c, inp, pressed) {
+ const st = p.st;
+ const inputDir = () => {
+ const dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0);
+ const dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0);
+ if (dx || dy) { const n = Math.hypot(dx, dy); return [dx / n, dy / n]; }
+ return [Math.cos(p.ang), Math.sin(p.ang)];
+ };
+ switch (c.key) {
+
+ case 'bolt': { // zoom dash, on a cooldown instead of landing-reset
+ if (st.dashCd > 0) st.dashCd--;
+ if ((pressed & B.SPC) && st.dashCd <= 0 && p.hitstun <= 0) {
+ const [ux, uy] = inputDir();
+ p.vx = ux * 8.6; p.vy = uy * 8.6;
+ p.ang = Math.atan2(uy, ux);
+ st.dashCd = 50;
+ g.events.push({ t: 'dash', x: p.x, y: p.y });
+ }
+ break;
+ }
+
+ case 'tank': {
+ if (st.chargeCd > 0) st.chargeCd--;
+ if (st.charge > 0) {
+ st.charge--;
+ p.vx = Math.cos(st.chargeAng) * 6.4;
+ p.vy = Math.sin(st.chargeAng) * 6.4;
+ for (const o of g.players) {
+ if (o !== p && alive(o) && o.z < 6 &&
+ Math.abs(o.x - p.x) < 18 && Math.abs(o.y - p.y) < 16) {
+ hurt(g, o, 10, Math.cos(st.chargeAng), Math.sin(st.chargeAng), 6.5);
+ st.charge = 0;
+ }
+ }
+ if (p.wallHit) { // x or y wall — stepPlayer encodes both
+ st.charge = 0;
+ g.events.push({ t: 'thud', x: p.x, y: p.y });
+ }
+ } else if ((pressed & B.SPC) && p.hitstun <= 0) {
+ if (p.z <= 0 && st.chargeCd <= 0) {
+ st.charge = 34; st.chargeCd = 80; st.chargeAng = p.ang;
+ g.events.push({ t: 'charge', x: p.x, y: p.y });
+ } else if (p.z > 0 && !st.pound) {
+ st.pound = true;
+ p.vz = -4.5; // slam down
+ }
+ }
+ break;
+ }
+
+ case 'swing': { // zip-leash: latch a wall, get reeled in, slingshot out
+ if (st.anchor) {
+ if (pressed & B.SPC) { st.anchor = null; break; }
+ const dx = st.anchor.x - p.x, dy = st.anchor.y - p.y;
+ const d = Math.hypot(dx, dy) || 1;
+ if (d < 16 || (pressed & B.HOP)) { // arrived, or slingshot release
+ st.anchor = null;
+ p.vx *= 1.18; p.vy *= 1.18;
+ break;
+ }
+ const ux = dx / d, uy = dy / d;
+ p.vx += (ux * 7 - p.vx) * 0.25;
+ p.vy += (uy * 7 - p.vy) * 0.25;
+ p.ang = Math.atan2(uy, ux);
+ } else if ((pressed & B.SPC) && p.hitstun <= 0) {
+ const hit = raycast(g.map, p.x, p.y, Math.cos(p.ang), Math.sin(p.ang), 175);
+ if (hit) {
+ st.anchor = { x: hit.x, y: hit.y };
+ g.events.push({ t: 'grapple', x: hit.x, y: hit.y });
+ } else {
+ g.events.push({ t: 'whiff', x: p.x, y: p.y });
+ }
+ }
+ break;
+ }
+
+ case 'wisp': { // blink works exactly as in side view — through walls, over pits
+ if (st.blinkCd > 0) st.blinkCd--;
+ if ((pressed & B.SPC) && st.blinkCd <= 0 && p.hitstun <= 0) {
+ const [ux, uy] = inputDir();
+ for (let dist = 58; dist >= 12; dist -= 6) {
+ const tx = p.x + ux * dist, ty = p.y + uy * dist;
+ if (boxFree(g.map, tx, ty, p.w, p.h)) {
+ g.events.push({ t: 'blink', x: p.x, y: p.y, x2: tx, y2: ty });
+ p.x = tx; p.y = ty;
+ p.vx = ux * 3.5; p.vy = uy * 3.5;
+ p.ang = Math.atan2(uy, ux);
+ st.blinkCd = 48;
+ break;
+ }
+ }
+ }
+ break;
+ }
+
+ case 'rocket': { // hover: floats over pits/spikes/saws while fuel lasts
+ st.hover = false;
+ if (p.z <= 0) st.fuel = Math.min(70, (st.fuel ?? 70) + 2.4);
+ if (st.fuel === undefined) st.fuel = 70;
+ if ((inp & B.SPC) && st.fuel > 0 && p.hitstun <= 0) {
+ st.fuel -= 1;
+ st.hover = true;
+ if (p.z < 5) p.vz = Math.max(p.vz, 0.9);
+ else { p.z = 5; p.vz = 0; }
+ if (g.tick % 5 === 0) g.events.push({ t: 'jet', x: p.x, y: p.y + 4 });
+ }
+ break;
+ }
+ }
+}
+
+function doAttack(g, p) {
+ const ax = p.x + Math.cos(p.ang) * 17, ay = p.y + Math.sin(p.ang) * 17;
+ const aw = 22, ah = 20;
+ for (const o of g.players) {
+ if (o === p || !alive(o)) continue;
+ if (p.hitMask & (1 << o.idx)) continue;
+ if (Math.abs(o.z - p.z) > 6) continue; // can't bite over/under a hopping dog
+ if (Math.abs(o.x - ax) < (aw + o.w) / 2 && Math.abs(o.y - ay) < (ah + o.h) / 2) {
+ p.hitMask |= 1 << o.idx;
+ const n = Math.hypot(o.x - p.x, o.y - p.y) || 1;
+ hurt(g, o, 8, (o.x - p.x) / n, (o.y - p.y) / n, 3.2);
+ p.vx *= 0.6; p.vy *= 0.6;
+ }
+ }
+ // crystals are full-height — mineable regardless of z
+ const x0 = Math.floor((ax - aw / 2) / TILE), x1 = Math.floor((ax + aw / 2) / TILE);
+ const y0 = Math.floor((ay - ah / 2) / TILE), y1 = Math.floor((ay + ah / 2) / TILE);
+ for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
+ if (tileAt(g.map, tx, ty) !== T.CRYSTAL) continue;
+ const i = ty * GW + tx;
+ if (p.minedSet[i]) continue;
+ p.minedSet[i] = 1;
+ const hp = (g.map.hp[i] ?? 3) - 1;
+ if (hp <= 0) breakTile(g, tx, ty, true);
+ else {
+ g.map.hp[i] = hp;
+ g.events.push({ t: 'crack', i, hp, x: tx * TILE + 6, y: ty * TILE + 6 });
+ }
+ }
+}
+
+function breakTile(g, tx, ty, drop) {
+ const i = ty * GW + tx;
+ g.map.tiles[i] = T.EMPTY;
+ delete g.map.hp[i];
+ g.events.push({ t: 'tile', i, v: T.EMPTY, x: tx * TILE + 6, y: ty * TILE + 6 });
+ if (drop) spawnPickup(g, tx * TILE + 6, ty * TILE + 6, g.rng.pick(ITEMS));
+}
+
+export function hurt(g, v, dmg, nx, ny, base) {
+ if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0 || v.falling > 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 = Math.max(-VCAP, Math.min(VCAP, nx * m));
+ v.vy = Math.max(-VCAP, Math.min(VCAP, ny * m));
+ v.vz = Math.max(v.vz, 1.4); // little pop for juice
+ if (v.z <= 0) v.z = 0.01;
+ 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;
+ g.events.push({ t: 'hit', x: v.x, y: v.y, m });
+ return true;
+}
+
+function useItem(g, p) {
+ const it = p.item;
+ const cx = Math.cos(p.ang), cy = Math.sin(p.ang);
+ if (it === 'ball') {
+ spawnEnt(g, { k: 'ball', x: p.x + cx * 12, y: p.y + cy * 12, vx: cx * 7 + p.vx * 0.3, vy: cy * 7 + p.vy * 0.3, w: 8, h: 8, own: p.idx, ttl: 240, age: 0 });
+ g.events.push({ t: 'throw', x: p.x, y: p.y });
+ if (--p.ammo <= 0) p.item = null;
+ } else if (it === 'bomb') {
+ spawnEnt(g, { k: 'bomb', x: p.x + cx * 10, y: p.y + cy * 10, vx: cx * 4.5 + p.vx * 0.4, vy: cy * 4.5 + p.vy * 0.4, w: 9, h: 9, own: p.idx, fuse: 75, age: 0 });
+ g.events.push({ t: 'throw', x: p.x, y: p.y });
+ p.item = null;
+ } else if (it === 'shield') {
+ p.shield = 260; p.item = null;
+ g.events.push({ t: 'shield', x: p.x, y: p.y });
+ } else if (it === 'boost') {
+ p.boost = 320; p.item = null;
+ g.events.push({ t: 'boost', x: p.x, y: p.y });
+ } else if (it === 'heal') {
+ p.dmg = Math.max(0, p.dmg - 35); p.item = null;
+ g.events.push({ t: 'heal', x: p.x, y: p.y });
+ }
+}
+
+function spawnEnt(g, e) {
+ e.id = g.nextEnt++;
+ g.ents.push(e);
+ return e;
+}
+
+function spawnPickup(g, x, y, item) {
+ spawnEnt(g, { k: 'pickup', x, y, vx: 0, vy: 0, w: 10, h: 10, item, ttl: 1400 });
+}
+
+function stepEnts(g) {
+ for (const e of g.ents) {
+ if (e.k === 'pickup') {
+ e.ttl--;
+ for (const p of g.players) {
+ if (alive(p) && !p.item && Math.abs(p.x - e.x) < 14 && Math.abs(p.y - e.y) < 14) {
+ p.item = e.item;
+ if (e.item === 'ball') p.ammo = 3;
+ e.dead = true;
+ g.events.push({ t: 'pickup', x: e.x, y: e.y, item: e.item });
+ break;
+ }
+ }
+ if (e.ttl <= 0) e.dead = true;
+
+ } else if (e.k === 'ball') {
+ e.age++; e.ttl--;
+ const pvx = e.vx, pvy = e.vy;
+ const r = moveEntity(e, g.map, {});
+ if (r.wall) e.vx = -pvx * 0.85;
+ if (r.ground || r.ceil) e.vy = -pvy * 0.85; // top-down: ±y walls bounce too
+ for (const p of g.players) {
+ if (!alive(p) || p.z > 6) continue;
+ if (p.idx === e.own && e.age < 30) continue;
+ if (Math.abs(p.x - e.x) < (p.w + e.w) / 2 && Math.abs(p.y - e.y) < (p.h + e.h) / 2) {
+ const n = Math.hypot(pvx, pvy) || 1;
+ hurt(g, p, 6, pvx / n, pvy / n, 3.5);
+ e.dead = true;
+ break;
+ }
+ }
+ // balls sail over pits (they're light); they just expire
+ if (e.ttl <= 0) e.dead = true;
+
+ } else if (e.k === 'bomb') {
+ e.age++; e.fuse--;
+ e.vx *= 0.96; e.vy *= 0.96; // slides to a stop
+ const pvx = e.vx, pvy = e.vy;
+ const r = moveEntity(e, g.map, {});
+ if (r.wall) e.vx = -pvx * 0.6;
+ if (r.ground || r.ceil) e.vy = -pvy * 0.6;
+ // a bomb sliding onto a pit drops in
+ if (tileAt(g.map, Math.floor(e.x / TILE), Math.floor(e.y / TILE)) === T.PIT) { e.dead = true; continue; }
+ let contact = false;
+ for (const p of g.players) {
+ if (!alive(p) || p.z > 8) continue;
+ if (p.idx === e.own && e.age < 15) continue;
+ if (Math.abs(p.x - e.x) < 12 && Math.abs(p.y - e.y) < 12) { contact = true; break; }
+ }
+ if (e.fuse <= 0 || contact) { explode(g, e.x, e.y); e.dead = true; }
+ }
+ }
+ g.ents = g.ents.filter(e => !e.dead);
+}
+
+function explode(g, x, y) {
+ g.events.push({ t: 'boom', x, y });
+ for (const p of g.players) {
+ if (!alive(p) || p.z > 8) continue;
+ const dx = p.x - x, dy = p.y - y;
+ const d = Math.hypot(dx, dy);
+ if (d < 48) {
+ const n = d || 1;
+ hurt(g, p, 14, dx / n, dy / n, 7);
+ }
+ }
+ const tx0 = Math.floor((x - 48) / TILE), tx1 = Math.floor((x + 48) / TILE);
+ const ty0 = Math.floor((y - 48) / TILE), ty1 = Math.floor((y + 48) / TILE);
+ for (let ty = ty0; ty <= ty1; ty++) for (let tx = tx0; tx <= tx1; tx++) {
+ if (tileAt(g.map, tx, ty) === T.CRYSTAL &&
+ Math.hypot(tx * TILE + 6 - x, ty * TILE + 6 - y) < 48) {
+ breakTile(g, tx, ty, true);
+ }
+ }
+}
+
+function stepSaws(g) {
+ for (const s of g.map.saws) {
+ const pos = sawPosOf(s);
+ s.prog += s.speed / (pos.total || 1);
+ s.x = pos.x; s.y = pos.y;
+ if (g.freeze > 0 || g.over) continue;
+ for (const p of g.players) {
+ if (!alive(p) || p.z > 5) continue; // hop over the blade
+ const dx = p.x - s.x, dy = p.y - s.y;
+ const d = Math.hypot(dx, dy);
+ if (d < 16) {
+ const n = d || 1;
+ if (hurt(g, p, 12, dx / n, dy / n, 6.5)) {
+ g.events.push({ t: 'saw', x: p.x, y: p.y });
+ }
+ }
+ }
+ }
+}
+
+function sawPosOf(s) { return sawPos(s, s.prog); }
+
+function kill(g, p) {
+ p.stocks--;
+ g.events.push({
+ t: 'ko', idx: p.idx,
+ 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.shield = 0; p.boost = 0;
+ p.vx = 0; p.vy = 0; p.vz = 0; p.z = 0; p.falling = 0;
+ p.hitstun = 0; p.atkT = 0; p.dmg = 0;
+ p.x = -1000; p.y = -1000;
+ p.respawn = p.stocks > 0 ? 80 : 0;
+}
+
+function respawn(g, p) {
+ let best = g.map.spawns[0], bd = -1;
+ for (const sp of g.map.spawns) {
+ let d = 1e9;
+ for (const o of g.players) {
+ if (o !== p && alive(o)) d = Math.min(d, Math.hypot(o.x - sp.x, o.y - sp.y));
+ }
+ if (d > bd) { bd = d; best = sp; }
+ }
+ p.x = best.x; p.y = best.y;
+ p.vx = 0; p.vy = 0; p.z = 0; p.vz = 0;
+ p.invuln = 90; p.dmg = 0;
+ g.events.push({ t: 'respawn', x: p.x, y: p.y });
+}
+
+function checkEnd(g) {
+ if (g.over) { g.overT++; return; }
+ const standing = g.players.filter(p => p.stocks > 0);
+ if (standing.length <= 1 && g.players.length > 1) {
+ g.over = { winner: standing[0] ? standing[0].idx : -1 };
+ g.overT = 0;
+ g.events.push({ t: 'over', winner: g.over.winner });
+ }
+}
+
+// --- snapshots: same contract as sim.js, plus z / facing-angle / falling ---
+export function snapshot(g, events) {
+ return {
+ tick: g.tick, freeze: g.freeze, over: g.over, overT: g.overT,
+ p: g.players.map(p => ({
+ x: +p.x.toFixed(1), y: +p.y.toFixed(1),
+ vx: +p.vx.toFixed(2), vy: +p.vy.toFixed(2),
+ z: +p.z.toFixed(1), fa: +p.ang.toFixed(2), fl: p.falling,
+ f: p.face, d: Math.round(p.dmg), s: p.stocks,
+ og: p.z <= 0 ? 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: {
+ an: p.st.anchor ? [Math.round(p.st.anchor.x), Math.round(p.st.anchor.y)] : 0,
+ ch: p.st.charge > 0 ? 1 : 0, cc: p.st.chargeCd | 0,
+ jet: p.st.hover ? 1 : 0, fu: p.st.fuel | 0,
+ po: p.st.pound ? 1 : 0, da: (p.st.dashCd | 0) <= 0 ? 1 : 0, bc: p.st.blinkCd | 0,
+ },
+ })),
+ e: g.ents.map(e => ({ id: e.id, k: e.k, x: Math.round(e.x), y: Math.round(e.y), item: e.item, f: e.fuse })),
+ sw: g.map.saws.map(s => [Math.round(s.x || 0), Math.round(s.y || 0)]),
+ ev: events,
+ };
+}
+
+export function applySnapshot(g, s) {
+ g.tick = s.tick; g.freeze = s.freeze; g.over = s.over; g.overT = s.overT;
+ s.p.forEach((sp, i) => {
+ const p = g.players[i];
+ if (!p) return;
+ p.x = sp.x; p.y = sp.y; p.vx = sp.vx; p.vy = sp.vy;
+ p.z = sp.z; p.ang = sp.fa; p.falling = sp.fl;
+ 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 = {
+ anchor: sp.st.an ? { x: sp.st.an[0], y: sp.st.an[1] } : null,
+ charge: sp.st.ch, chargeCd: sp.st.cc, hover: sp.st.jet, jet: sp.st.jet,
+ fuel: sp.st.fu, pound: sp.st.po, dash: sp.st.da, blinkCd: sp.st.bc,
+ };
+ });
+ g.ents = s.e.map(e => ({ ...e, fuse: e.f, vx: 0, vy: 0, w: 8, h: 8 }));
+ s.sw.forEach((xy, i) => {
+ const saw = g.map.saws[i];
+ if (saw) { saw.x = xy[0]; saw.y = xy[1]; }
+ });
+ for (const ev of s.ev) {
+ if (ev.t === 'tile') { g.map.tiles[ev.i] = ev.v; delete g.map.hp[ev.i]; }
+ else if (ev.t === 'crack') g.map.hp[ev.i] = ev.hp;
+ }
+ return s.ev;
+}
diff --git a/style.css b/style.css
index c8baa07..03e8d63 100644
--- a/style.css
+++ b/style.css
@@ -102,6 +102,15 @@ input {
/* char select */
.screen.wide { justify-content: center; }
+.modebtn {
+ min-width: 220px;
+ font-size: 15px;
+ border-color: #555;
+ opacity: 0.6;
+}
+.modebtn small { display: block; font-size: 10px; opacity: 0.7; }
+.modebtn.sel { border-color: #ffd740; color: #ffd740; opacity: 1; }
+#mode-hint { margin-top: -6px; }
#char-grid {
display: flex; gap: 10px;
flex-wrap: wrap; justify-content: center;
diff --git a/test/smoke.mjs b/test/smoke.mjs
index 85a2a35..bcff2bc 100644
--- a/test/smoke.mjs
+++ b/test/smoke.mjs
@@ -1,7 +1,9 @@
// Headless smoke test for the DOM-free sim core.
// Runs full matches with random inputs across many seeds and asserts sanity.
import { createGame, step, snapshot, applySnapshot } from '../js/sim.js';
+import * as arena from '../js/sim_arena.js';
import { generateMap, sawPos } from '../js/mapgen.js';
+import { generateArena } from '../js/mapgen_arena.js';
import { mulberry32 } from '../js/rng.js';
import { boxFree, solidAtPx } from '../js/physics.js';
import { GW, GH, T, B } from '../js/consts.js';
@@ -132,5 +134,127 @@ ok('sim: 12 chaotic matches, all character matchups, finite state throughout');
else ok('stocks: 3 KOs end the match with the right winner');
}
+// ===========================================================================
+// Top-down "Dogpit" arena mode
+
+// --- arena mapgen: determinism, symmetry, connectivity ---
+for (let s = 1; s <= 30; s++) {
+ const seed = (s * 1103515245 + 12345) >>> 0;
+ const map = generateArena(seed);
+ if (map.spawns.length !== 4) fail(`arena seed ${seed}: expected 4 spawns`);
+ const map2 = generateArena(seed);
+ for (let i = 0; i < map.tiles.length; i++) {
+ if (map.tiles[i] !== map2.tiles[i]) { fail(`arena seed ${seed}: non-deterministic`); break; }
+ }
+ let pits = 0, walls = 0, crystals = 0;
+ for (const t of map.tiles) { if (t === T.PIT) pits++; if (t === T.SOLID) walls++; if (t === T.CRYSTAL) crystals++; }
+ if (pits < 4) fail(`arena seed ${seed}: too few pit tiles (${pits})`);
+ if (crystals < 2) fail(`arena seed ${seed}: too few crystals (${crystals})`);
+ // mirror symmetry of walls and pits
+ for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
+ const a = map.tiles[y * GW + x], b = map.tiles[y * GW + (GW - 1 - x)];
+ const cls = (t) => (t === T.SOLID ? 1 : t === T.PIT ? 2 : 0);
+ if (cls(a) !== cls(b)) { fail(`arena seed ${seed}: asymmetric at ${x},${y}`); y = GH; break; }
+ }
+ // spawns clear + all mutually reachable over walkable tiles (corridors guarantee it)
+ for (const sp of map.spawns) {
+ if (!boxFree(map, sp.x, sp.y, 18, 14)) fail(`arena seed ${seed}: spawn blocked`);
+ }
+ {
+ const walkable = (t) => t === T.EMPTY || t === T.SPIKE || t === T.PADTOP;
+ const seen = new Uint8Array(GW * GH);
+ const q = [[Math.floor(map.spawns[0].x / 12), Math.floor(map.spawns[0].y / 12)]];
+ seen[q[0][1] * GW + q[0][0]] = 1;
+ while (q.length) {
+ const [x, y] = q.pop();
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = x + dx, ny = y + dy;
+ if (nx < 0 || nx >= GW || ny < 0 || ny >= GH) continue;
+ const i = ny * GW + nx;
+ if (!seen[i] && walkable(map.tiles[i])) { seen[i] = 1; q.push([nx, ny]); }
+ }
+ }
+ for (const sp of map.spawns) {
+ if (!seen[Math.floor(sp.y / 12) * GW + Math.floor(sp.x / 12)]) fail(`arena seed ${seed}: spawn unreachable`);
+ }
+ }
+}
+ok('arena mapgen: 30 seeds, deterministic, mirrored, connected, clear spawns');
+
+// --- arena chaotic matches (hop + specials in the input mix) ---
+const ARENA_BITS = [0, B.L, B.R, B.U, B.D, B.L | B.U, B.R | B.D, B.HOP, B.R | B.HOP,
+ B.ATK, B.SPC, B.U | B.SPC, B.L | B.D | B.SPC, B.ITM, B.HOP | B.ATK, B.R | B.U | B.SPC | B.HOP];
+for (let trial = 0; trial < 10; trial++) {
+ const seed = (trial * 69069 + 1) >>> 0;
+ const rng = mulberry32(seed ^ 0xA1A1);
+ const g = arena.createGame(seed, [{ charId: trial % 5 }, { charId: (trial + 2) % 5 }]);
+ const inputs = [0, 0];
+ for (let t = 0; t < 12000 && !g.over; t++) {
+ if (t % 7 === 0) inputs[0] = ARENA_BITS[Math.floor(rng() * ARENA_BITS.length)];
+ if (t % 5 === 0) inputs[1] = ARENA_BITS[Math.floor(rng() * ARENA_BITS.length)];
+ const evs = arena.step(g, inputs);
+ for (const p of g.players) {
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.z)) {
+ fail(`arena trial ${trial} tick ${t}: non-finite player state`);
+ t = 1e9; break;
+ }
+ }
+ if (t % 500 === 250) {
+ const snap = JSON.parse(JSON.stringify(arena.snapshot(g, evs)));
+ const g2 = arena.createGame(seed, [{ charId: trial % 5 }, { charId: (trial + 2) % 5 }]);
+ arena.applySnapshot(g2, snap);
+ if (Math.abs(g2.players[0].x - g.players[0].x) > 0.2 || Math.abs((g2.players[0].z || 0) - g.players[0].z) > 0.2) {
+ fail(`arena trial ${trial}: snapshot round-trip diverged`);
+ }
+ }
+ }
+}
+ok('arena sim: 10 chaotic matches, finite state, snapshot round-trips');
+
+// --- targeted: walking onto a pit KOs after the fall animation ---
+{
+ const g = arena.createGame(777, [{ charId: 0 }, { charId: 1 }]);
+ g.freeze = 0;
+ const p = g.players[0];
+ // find a pit tile and park the dog on it
+ let pit = -1;
+ for (let i = 0; i < g.map.tiles.length; i++) if (g.map.tiles[i] === T.PIT) { pit = i; break; }
+ if (pit < 0) fail('arena seed 777 has no pit');
+ else {
+ p.x = (pit % GW) * 12 + 6; p.y = Math.floor(pit / GW) * 12 + 6; p.z = 0;
+ const before = p.stocks;
+ let fell = false;
+ for (let i = 0; i < 120; i++) {
+ const evs = arena.step(g, [0, 0]);
+ if (evs.some(e => e.t === 'fall')) fell = true;
+ if (p.stocks < before) break;
+ }
+ if (!fell) fail('pit never triggered a fall');
+ if (p.stocks !== before - 1) fail('pit fall did not cost a stock');
+ else ok('arena pits: falling in costs a stock');
+ }
+}
+
+// --- targeted: hop crosses a pit ---
+{
+ const g = arena.createGame(777, [{ charId: 0 }, { charId: 1 }]);
+ g.freeze = 0;
+ const p = g.players[0];
+ // 3-wide pit straight ahead; hop from the edge while running right
+ const ty = Math.floor(p.y / 12);
+ const tx = Math.floor(p.x / 12);
+ for (let i = 1; i <= 3; i++) g.map.tiles[ty * GW + tx + 1 + i] = T.PIT;
+ // run right to build speed, hop at the lip
+ let lost = false;
+ for (let i = 0; i < 120; i++) {
+ const overLip = Math.floor(p.x / 12) >= tx + 1;
+ arena.step(g, [B.R | (overLip && p.z <= 0 && p.falling <= 0 ? B.HOP : 0), 0]);
+ if (p.falling > 0 || p.stocks < 3) { lost = true; break; }
+ if (Math.floor(p.x / 12) > tx + 4) break; // made it across
+ }
+ if (lost) fail('hop could not cross a 3-tile pit');
+ else ok('arena hop: clears a 3-tile pit at speed');
+}
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll smoke tests passed.');
process.exit(failures ? 1 : 0);