diff --git a/HANDOFF.md b/HANDOFF.md
index ca2d718..757a864 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -317,8 +317,74 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`updateZombies`), never by handing back movement.
- **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about
±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the
- frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so
- every legal ball stays catchable.
+ frame entirely. `xLimit()` in gameplay — the camera limit, floored at the sideline so every legal
+ ball stays catchable. The walk and the sidestep share it.
+- **The sidestep is how you move outside `rally`, and it is not a walk.** One committed step
+ (`TUNE.dodgeDist`) on a cooldown, carrying `TUNE.dodgeIFrame` seconds that beat BOTH damage
+ channels — the chip damage in `hurtPlayer` and the instant-death contact test. It exists because a
+ boss fight is spent in `pretoss`, so the rule above left you welded to the spot for the whole
+ fight and Oliver died at every chapter boss without ever being able to move.
+- **Which way you leave the deadzone IS the gesture.** Decided once, at the `DEAD` crossing, and
+ never revisited for that touch: down-ish = the slingshot, sideways = the sidestep. This is how the
+ new gesture stays inside "one gesture means one thing" — there is no timing window and no hidden
+ mode, only the direction you actually moved. The finger-steer modes (horde, burst) are untouched:
+ `stick.aimable` is false there, so the drag is still the absolute steer it always was.
+- **Two type registers, and the register IS the message's category.** Silkscreen for the HUD, the
+ rack, the control hints and `RESUMING IN` — things you read at leisure. Bangers (`B()` in render)
+ for `SET`, `CHAPTER`, `EPIC!`, `PERFECT` and every free-floating flash — things shouted at you.
+ Free-floating text carries a 1px shadow (`shadowText`) and one shared y (`FLASH_Y`), because it
+ has no panel and has to survive the crowd, sunlit clay and blood behind it.
+
+## Session log: the sidestep, and two type registers (2026-08-03)
+
+Oliver's post-ship playtest. One blocker and seven polish items; all eight landed.
+
+**The blocker: he died at every chapter boss because he could not move.** Two independent causes,
+and fixing either alone would have left him rooted. The movement block is `rally`-only (a settled
+decision the six boss AI styles were written around), and a boss fight is spent in `pretoss`. On top
+of that `canAimServe()` is true through a boss fight, so the one finger was already spent on the
+slingshot — there was no gesture left over that *could* mean "move". The answer is a **sidestep**,
+not a walk, so the settled decision stands; and the gesture is picked by the DIRECTION the drag
+leaves the deadzone in, so nothing about "one gesture means one thing" bends. Both are in the
+settled-decisions list above.
+
+Two guards make the dodge worth spending: `hurtPlayer` (the ranged channel) and the contact test in
+`updateZombies` (the instant-death channel). Verified by pinning a shambler on the player: alive at
+`dodgeI` 0.3, eaten the frame it hit 0.
+
+**The message system was seven unrelated looks; it is two registers now.** Bangers is bundled
+alongside Silkscreen and the split is documented above. `drawCard`, `drawModal`'s title, `G.flash`
+and the death summary shout; the control hints, the pull/dodge demos, the scoreboard and
+`RESUMING IN` stay quiet. The types Oliver had not counted, for the record: the bottom control-hint
+slot at `METER_Y`, the `drawPullHint` apron demo, the death summary, and the scoreboard/rack chrome.
+
+**Modals queue now.** `showModal` used to *drop* a second popup opened in the same beat, which is
+why the trainer and the weapon had to be crammed into one card after a won duel. `G.modalQ` is four
+lines and it retires that hack: the weapon modal, then a real `WOUNDS TREATED` modal with the
+`x_cross` first-aid tin over it (the sprite had been preloaded and drawn by nothing since the merge).
+
+**One popup, one rule.** `weaponModal`'s first-sight gate moved INSIDE the function. The barrels
+gated themselves and the tin ladder did not, so four weapons re-explained themselves every three
+games for a whole run while a barrel weapon explained itself once. `deliverTin` gained the label
+flash a barrel already had, or the fifth delivery onward would pay out in silence.
+
+**Ten rack slots, second row on demand.** `RACK_MAX` 4 → 9. Ten tiles across 480px would be ~39 CSS
+px on a phone, under the thumb target `rackHit` was widened for, so past five it grows a second row
+UPWARD and slots 0-4 stay welded to the bottom edge. `METER_Y` derives from `rackTop()` now, so the
+hint line and the apron demo lift with it.
+
+**The apron demo alternates.** `demoSwipe(dx, dy, label)` is the old `drawPullHint` body with the
+direction pulled out; `drawPullHint` alternates PULL DOWN and SWIPE TO DODGE on the 1.9s cycle, and
+mimes away from whichever edge the player is standing against.
+
+**The transition punches in.** `G.zoom` is a flat 2D scale about a projected world point, applied in
+the same place and for the same reason as `G.shake` — a perspective dolly would want `camZ`,
+`aimCam` and every projected HUD anchor to agree for the sake of one 1.15s beat. Escalating shake at
+3.4 / 4.8 / 8.2 s.
+
+**And the boss win holds.** `TUNE.bossWinT` (3.6s) is its own number so the routine SET card does not
+drag with it. `EPIC!` in Bangers, chapter and crowd demoted to subs, and no `GAME n NEXT` line —
+the bar under the scoreboard already prints the game number permanently.
## Session log: the second pass on the barrel weapons (2026-08-03)
diff --git a/assets/fonts/Bangers-Regular.ttf b/assets/fonts/Bangers-Regular.ttf
new file mode 100644
index 0000000..df7d5dc
Binary files /dev/null and b/assets/fonts/Bangers-Regular.ttf differ
diff --git a/config.js b/config.js
index 7c35238..c9d92a5 100644
--- a/config.js
+++ b/config.js
@@ -27,6 +27,15 @@ export const TUNE = {
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
+ // ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which
+ // left you rooted to the spot through a whole boss fight — the drag is the slingshot and there was
+ // no gesture left over that could mean "move". A dodge is not a walk: it is one committed step
+ // with a cooldown, so it answers a volley without turning the serve stance into a joystick.
+ dodgeDist: 1.9, // m of ground the sidestep covers
+ dodgeT: 0.18, // s it takes — fast enough to be a reaction, slow enough to read as a step
+ dodgeCool: 0.5, // s before another. THE anti-spam knob: shorter and you skate across the court
+ dodgeIFrame: 0.28,// s untouchable, covering the step and a hair after. Beats BOTH damage channels
+ // (a boss volley and the instant-death body), which is the whole point of it
zSpeed: 0.7, // zombie shamble speed m/s
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why
// a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed
@@ -307,6 +316,9 @@ export const TUNE = {
// full pierce simply killed the whole lane outright and nothing was left to catch fire.
burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting)
setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
+ bossWinT: 3.6, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is
+ // the longest fight in the game and the win was over before it registered, but
+ // stretching setCardT would have made every routine set announcement drag too.
modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the
// left, each on easeOutBack. The gesture that dismisses is swallowed until it
// lands, so a panic tap can't skip a popup before it has been read.
@@ -527,7 +539,12 @@ export const BARREL_WEAPONS = {
laser: { weight: 2, from: 6 },
rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three
};
-export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent
+// Gathered weapons the rack holds (plus slot 0 = BALL, so ten tiles). Was 4, and at four you were
+// throwing away a barrel almost every time one popped — Oliver: "that makes replacing the ball rarer
+// if not unnecessary at all". The `temp` valve below survives for the eleventh, which nobody will see.
+export const RACK_MAX = 9;
+export const RACK_ROW = 5; // tiles per row — past five the rack grows a SECOND row upward, so a tile
+ // never shrinks below the thumb target rackHit was widened for
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
// (a body is PLAYER_H 1.88), but big enough to be a thing on the court.
diff --git a/gameplay.js b/gameplay.js
index 9438b3d..ed6e909 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -310,12 +310,15 @@ function scoreKey() {
}
// ---- the modal: the only thing in the game that stops the world. Play resumes on a tap, not
-// on a timer, so a popup can be READ. showModal never clobbers an open one (a kill cascade can
-// try to open two in the same frame) and only ever touches G — never the zombie array it may
-// be called from mid-iteration.
+// on a timer, so a popup can be READ. It only ever touches G — never the zombie array it may be
+// called from mid-iteration.
+// A second popup opened in the same beat QUEUES rather than being dropped. It used to be dropped
+// ("if (G.modal) return"), which is why the trainer and the weapon had to be crammed into one card
+// after a won duel — the second one simply vanished. They are two things, so they are two popups.
export function showModal(m) {
- if (G.modal) return;
- m.in = 0; G.modal = m; whoosh(); // 0..1 entrance clock, ticked in update() behind the freeze
+ m.in = 0; // 0..1 entrance clock, ticked in update() behind the freeze
+ if (G.modal) { G.modalQ.push(m); return; }
+ G.modal = m; whoosh();
}
export function dismissModal() {
const m = G.modal; if (!m) return false;
@@ -323,7 +326,9 @@ export function dismissModal() {
// skippable by the same panic tap that was mid-air when it appeared — and returning false here
// would let that tap fall through into a serve, which is the swallowed-gesture bug all over again.
if (m.in < 1) return true;
- G.modal = null; if (m.onDismiss) m.onDismiss();
+ G.modal = G.modalQ.shift() || null;
+ if (G.modal) { G.modal.in = 0; whoosh(); } // the next one flies in on its own entrance
+ if (m.onDismiss) m.onDismiss();
return true; // the caller must swallow the gesture: dismissing must never also serve
}
@@ -528,24 +533,28 @@ export function spendSlot(i) {
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
// instead of spending it now. G.weapon picks which tin the next serve throws.
const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out
-export function deliverTin(healLine = null) {
+export function deliverTin() {
const id = TINS[G.deliveries++ % TINS.length];
const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking)
grantWeapon(id);
if (wasPlain) G.weapon = 0;
sendBoy(); voice('new_balls', 0.6);
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
- // healLine folds the trainer's visit INTO this popup. `showModal` deliberately never clobbers an
- // open modal (a kill cascade can try to open two in one frame), so when both happened the heal
- // modal opened first and the weapon one was lost entirely — Oliver: "the weapon message is lost".
- weaponModal(id, healLine);
+ // From the fifth delivery on, weaponModal has nothing new to explain and stays quiet — so the tin
+ // needs the same free-floating label a barrel already gets, or a won duel pays out in silence.
+ G.flash = { text: WEAPONS[id].label, t: 1.1 };
+ weaponModal(id);
}
// The one-per-run "what this thing does" popup, shared by the duel ladder and the barrels.
-export function weaponModal(id, healLine = null) {
+// THE GATE LIVES HERE, not at the call sites. The barrels gated themselves and the tin ladder did
+// not, so the four delivered weapons re-explained themselves every three games for a whole run
+// while a barrel weapon explained itself once — the same popup behaving two different ways.
+export function weaponModal(id) {
+ if (G.seen['w' + id]) return;
+ G.seen['w' + id] = true;
const w = WEAPONS[id];
- showModal({ title: w.label, body: healLine ? [...w.desc, '', healLine] : w.desc,
- icon: w.icon, iconH: 40, color: UI.warm });
+ showModal({ title: w.label, body: w.desc, icon: w.icon, iconH: 40, color: UI.warm });
}
// ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing
@@ -621,6 +630,7 @@ export function startBossFight(chapter) {
G.seen.boss = true;
showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad,
body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY',
+ 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY',
'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] });
}
}
@@ -632,10 +642,14 @@ export function endBossFight() {
zombieDuck(false);
shots.length = 0;
cheer(3.4, 0.9); voice('unbelievable', 0.3);
- G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET',
- sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT,
+ // The longest fight in the game deserves more than the two seconds a routine set gets. Big word,
+ // long hold — the chapter and the crowd move down to the subs where they belong.
+ // (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a
+ // card that repeats what the HUD already says is the clutter this pass is meant to remove.)
+ G.card = { title: 'EPIC!', sub1: 'CHAPTER ' + c + ' DOWN',
+ sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT,
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
- ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT + TUNE.bossDeathT;
+ ball.active = false; G.phase = 'over'; G.overT = TUNE.bossWinT + TUNE.bossDeathT;
// hand back the game beat the boss interrupted — same contract resolveInterlude uses
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
}
@@ -647,16 +661,17 @@ export function resolveInterlude(side) {
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
- // winning the duel patches you up. THE reason to want a duel rather than merely tolerate it —
- // it is the only healing in the game, and blood is the only thing you cannot buy back otherwise.
- let healLine = null;
+ deliverTin(); // win the point, win the next tin
+ // ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
+ // the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
+ // gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
+ // of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
if (G.hp < G.hpMax) {
- const before = G.hp;
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
voice('ump_trainer', 0.2, 0.95);
- healLine = '+ WOUNDS TREATED · NOW ' + G.hp + '/' + G.hpMax;
+ showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
+ body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
}
- deliverTin(healLine); // win the point, win the next tin — and the trainer, in ONE popup
} else {
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
score.games[1]++; G.runGames++; // it goes on the scoreboard
@@ -678,6 +693,30 @@ export function spentBall() {
if (!G.changeover) G.overT = TUNE.splatterOverT;
}
+// ---- THE SIDESTEP. Free walking is 'rally'-only (see the movement block) and that is staying —
+// but it left you welded to the spot through a whole boss fight, because a boss is fought from the
+// serve stance and the one finger is already spent on the slingshot. So: a horizontal swipe is a
+// committed step, not a joystick. It integrates OUTSIDE the movement block so it works in every
+// phase, and it carries invulnerability, which is the only reason to make one.
+export function dodge(dir) {
+ if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return;
+ G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame;
+ G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls
+ // G.aim to fire this, and the demo's own guard only watches G.aim.
+ P.facing = dir;
+ // ...but a swing in flight keeps its pose. You can step out of the way mid-shot and the shot still
+ // lands (swingT is untouched); clobbering the pose only threw away the animation for it.
+ if (P.pose !== 'swing' && P.pose !== 'serve') { P.pose = 'run'; P.poseT = 0; }
+ swish(0.22);
+}
+
+// How far out he may stand. P_BOUNDS.x is 6.5 m, but at the baseline in portrait the camera only
+// shows about ±4 m either side of the centre line, so walking to the bound put him clean off the
+// edge. Floored at the sideline so every legal tennis ball stays catchable — the court is never out
+// of bounds. Shared by the movement block and the dodge; both were inline in the former.
+const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
+const xLimit = () => Math.max(xVisible(), COURT.W / 2);
+
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
function startHorde() {
if (G.horde) return;
@@ -725,16 +764,24 @@ function transition(dt) {
const t = (G.transT += dt);
const once = (key, at, fn) => { if (t >= at && !G.tFlags[key]) { G.tFlags[key] = true; fn(); } };
once('quiet', 1.8, () => voice('quiet_dark', 0, 0.82));
- once('turn', 3.4, () => { spawnZombie(O.x, O.z, 'c'); G.hideO = true; }); // the opponent... turns
- once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); });
+ // The turn is the moment the game tells you what it now is, and it used to happen in a wide
+ // static shot at the far baseline where it was three pixels tall. The camera PUNCHES IN on him
+ // for it (a flat 2D zoom, see render), and the ground goes with it. Each later beat shakes harder.
+ once('turn', 3.4, () => {
+ spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns
+ G.zoom = { x: O.x, z: O.z, k: 1.9, t: 0, T: 1.15 };
+ G.shake = { t: 0.45, mag: 4 };
+ });
+ once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; });
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
once('go', 8.2, () => {
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
+ G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
// the mechanic changed under you — the one popup that gets an illustration, because it has
// to TEACH the gesture, not just announce it
showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120,
body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS',
- 'A WEAK SHOT ONLY STAGGERS THEM'] });
+ 'A WEAK SHOT ONLY STAGGERS THEM', 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY'] });
});
}
@@ -1004,6 +1051,12 @@ function meleeCooldown() {
}
export function keyPressed(code) {
+ // Desktop sidestep: an arrow/A/D EDGE outside 'rally', where the walk axes do nothing anyway.
+ // Inside a rally the same keys still walk, so one key never means two things at one moment.
+ if (code === 'ArrowLeft' || code === 'KeyA' || code === 'ArrowRight' || code === 'KeyD') {
+ if (G.phase !== 'rally') dodge(code === 'ArrowLeft' || code === 'KeyA' ? -1 : 1);
+ return;
+ }
if (code !== 'Space') return;
audio(); // unlock on first gesture
// a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the
@@ -1132,6 +1185,7 @@ export function update(dt) {
else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
}
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
+ if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it)
if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null;
if (G.boomFlash > 0) G.boomFlash -= dt;
// A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers,
@@ -1173,7 +1227,7 @@ export function update(dt) {
// auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped
// to what the camera can see (portrait's world bounds are wider than its viewport)
const mgMode = G.horde || G.mgT > 0;
- const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
+ const xVis = xVisible();
let mgSteer = false, mgMoving = false;
if (mgMode && touchPX() !== null) {
mgSteer = true;
@@ -1200,17 +1254,14 @@ export function update(dt) {
assist = dx;
}
}
- // Stay ON SCREEN — always, not just in auto-fire. P_BOUNDS.x is 6.5 m, but at the baseline in
- // portrait the camera only shows about ±4 m either side of the centre line, so walking to the
- // bound put the player clean off the edge (x = -6.5 projects to screen -61 of 270). Floored at
- // the sideline so every legal tennis ball stays catchable — the court is never out of bounds.
- const xLim = Math.max(xVis, COURT.W / 2);
+ // Stay ON SCREEN — always, not just in auto-fire (x = -6.5 projects to screen -61 of 270).
+ const xLim = xLimit();
P.x = Math.max(-xLim, Math.min(xLim, P.x));
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
// mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a
// player sliding left and right under auto-fire counted as standing still and never ran —
// "when he's moving left and right the run animation is not applied".
- const moving = mgMoving || mx || mz || assist !== 0;
+ const moving = mgMoving || mx || mz || assist !== 0 || G.dodgeT > 0;
P.runT += moving ? dt : 0;
// Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then
// faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic
@@ -1223,7 +1274,19 @@ export function update(dt) {
// ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only
// handles swing/serve), and P.runT stops advancing outside the block, so a beat that began
// mid-stride left him welded to a single run frame — the "stuck mid-animation" report.
- else if (P.pose === 'run') { P.pose = 'idle'; P.poseT = 0; }
+ else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; }
+ // The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent
+ // in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him
+ // off the edge of the screen either.
+ if (G.dodgeCd > 0) G.dodgeCd -= dt;
+ if (G.dodgeI > 0) G.dodgeI -= dt;
+ if (G.dodgeT > 0) {
+ G.dodgeT -= dt;
+ const xLim = xLimit();
+ P.x = Math.max(-xLim, Math.min(xLim, P.x + G.dodgeDir * (TUNE.dodgeDist / TUNE.dodgeT) * dt));
+ P.runT += dt; // the legs move, so the run cycle advances
+ if (G.dodgeT <= 0) G.dodgeDir = 0;
+ }
// splatter: you START at the baseline but you're free to advance up-court (bounded at the net by
// P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still
// recovers you toward the baseline between shots on touch; the duel is plain tennis as ever.
diff --git a/index.html b/index.html
index d1cecdb..8b1502c 100644
--- a/index.html
+++ b/index.html
@@ -20,6 +20,11 @@
src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); }
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
src: url('assets/fonts/Silkscreen-Bold.ttf') format('truetype'); }
+ /* the ANNOUNCEMENT face. Silkscreen is the HUD and the instructions — small, even, quiet. The
+ things that shout (SET, CHAPTER, EPIC!, PERFECT) are a second register on purpose, so a message
+ tells you which kind it is before you have read a word of it. Bundled, like the other one. */
+ @font-face { font-family: 'Bangers'; font-weight: 400; font-display: block;
+ src: url('assets/fonts/Bangers-Regular.ttf') format('truetype'); }
/* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is
laid out in the root's own box — and with the canvas position:fixed the root has no content and
collapses, so the gradient render.js sets had nowhere to go. */
@@ -179,6 +184,6 @@
-
+