The blood you have left, the bodies behind you, and a face for staging

Three things, all in service of the match reading as its own mode:

BLOOD, COUNTED. The vignette says "worse" and never says "how many". One pip
per hit left, top-right beside the board, in the panel language the game/sets
bar already uses; the last two breathe. Pips rather than a bar on purpose —
the pool is 4 or 8, and a smooth readout of a small integer is the exact
ambiguity being fixed.

OPPONENTS FELLED IS THE MEASURE (handoff slice 1). Blood-only scoring means a
lost game costs nothing but the blood already spent, so games survived is not
the achievement. The in-play bar prints OPPONENT n · m FELLED instead of the
game/sets line, the death screen says BLED OUT / FELLED n OPPONENTS · m GAMES,
and it keeps its own matchBest — drillBest's pattern. The kills-to-next-point
pips are hidden in a match: nothing there is killed for points, and they were
colliding with the new line anyway.

STAGING WEARS A DIFFERENT ICON. Both builds live on the same phone and two
identical bloody tennis balls is a coin toss. Driven by HOSTNAME, not by the
branch — the block is identical on staging and main, so it never conflicts on
a merge and never has to be remembered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 08:53:02 +02:00
1 parent a54cfa5f4f
commit 8011186cda
10 files changed
+113 -9

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ FROM python:3.12-alpine
# ponytail: switch to hashed filenames if traffic ever matters.
WORKDIR /app
COPY --from=test /tests-passed /tmp/tests-passed
COPY index.html manifest.webmanifest server.py /app/
COPY index.html manifest.webmanifest manifest-stg.webmanifest server.py /app/
COPY src /app/src
COPY assets /app/assets
# THE SCORES LIVE HERE AND THIS MUST BE A PERSISTENT VOLUME. Without one the json sits in the
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+17 -1
View File
@@ -14,6 +14,22 @@
<meta name="apple-mobile-web-app-title" content="Splatter">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#2b2735">
<!-- STAGING WEARS A DIFFERENT FACE. Both builds get added to the same phone's homescreen, and two
identical bloody tennis balls is a coin toss every time you open one. Driven by HOSTNAME, not by
the branch: this block is identical on staging and on main, so it never conflicts on a merge and
never has to be remembered. Prod is the one named host; everything else (staging, localhost, an
IP) is a test build and says so. Runs in <head>, before the install prompt can read any of it. -->
<script>
if (location.hostname !== 'splatter-tennis.wuwi.one') {
for (const [sel, href] of [['link[rel=icon]', 'assets/favicon-stg.png'],
['link[rel=apple-touch-icon]', 'assets/icon-180-stg.png'],
['link[rel=manifest]', 'manifest-stg.webmanifest']])
document.querySelector(sel).setAttribute('href', href);
document.querySelector('meta[name=apple-mobile-web-app-title]').setAttribute('content', 'Splatter STG');
document.querySelector('meta[name=theme-color]').setAttribute('content', '#14334a');
document.title = 'Splatter Tennis — STAGING';
}
</script>
<style>
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack.
?v= like the sprites: server.py caches a VERSIONED asset for a year and an unversioned one for
@@ -400,6 +416,6 @@
</div>
</div>
</div>
<script type="module" src="src/main.js?v=176"></script>
<script type="module" src="src/main.js?v=177"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"name": "Splatter STAGING",
"short_name": "Splatter STG",
"description": "The one that is allowed to be broken.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#10141c",
"theme_color": "#14334a",
"icons": [
{ "src": "assets/icon-192-stg.png", "sizes": "192x192", "type": "image/png" },
{ "src": "assets/icon-512-stg.png", "sizes": "512x512", "type": "image/png" },
{ "src": "assets/icon-512-stg.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+48 -7
View File
@@ -769,6 +769,34 @@ function drawDrillHud() {
}
}
// YOUR BLOOD, COUNTED. The vignette is the mood and it stays, but Oliver: "you do not immediately
// know when it is really urgent" — a wash that deepens smoothly cannot answer "how many more can I
// take", and in the match (8 hp) that is the whole question. So: one pip per hit you have left, in
// the top-right corner beside the scoreboard, in the same recessed-panel language the game/sets bar
// under the board already uses. Full pips are blood red, spent ones are an empty socket, and the
// LAST TWO breathe — the one state where the number matters more than the picture.
// ponytail: pips, not a bar. hpMax is 4 (gore) or 8 (match); a bar would be a smooth readout of a
// small integer, which is exactly the ambiguity being fixed. Switch to a bar if a pool ever passes ~12.
function drawBlood() {
if (G.mode !== 'splatter' || !G.hpMax) return;
const n = G.hpMax, hp = Math.max(0, Math.min(n, G.hp));
const r = 2.6, gap = n > 6 ? 7 : 9, pw = (n - 1) * gap + r * 2;
const w = Math.max(46, pw + 10), h = 24, x = W - 6 - w, y = 6;
panel(x + 0.5, y + 0.5, w - 1, h - 1, 3, 'rgba(26,6,8,0.82)', 'rgba(163,74,66,0.45)');
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.font = F(7, 0); ctx.fillStyle = hp <= 2 ? UI.bad : '#89a08f';
ctx.fillText('BLOOD', x + w / 2, y + 7);
// the low-blood breath: the whole row pulses once you are two hits from the end
const pulse = hp > 0 && hp <= 2 ? 0.55 + 0.45 * Math.abs(Math.sin(G.idleT * 5)) : 1;
const x0 = x + w / 2 - pw / 2 + r;
for (let i = 0; i < n; i++) {
ctx.beginPath(); ctx.arc(x0 + i * gap, y + 16, r, 0, 7);
if (i < hp) { ctx.fillStyle = hexA('#c8232a', pulse); ctx.fill(); }
else { ctx.strokeStyle = 'rgba(120,60,58,0.7)'; ctx.lineWidth = 1; ctx.stroke(); }
}
ctx.textBaseline = 'top';
}
// The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk
// well, a bevel, and a fill that runs gold -> orange -> red as the pool empties, so the colour says
// how the fight is going without reading the length. Integer-snapped and a FIXED pixel size — this
@@ -2040,9 +2068,15 @@ export function render() {
// which game you're in, and the sets banked, permanently — the 1.1s GAME N flash and the
// scoreboard's own per-row sets column were the only places either showed.
ctx.textAlign = 'right'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim;
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
// ...and in the MATCH the run is not measured in games at all (blood-only scoring): the ladder
// rung you are on and the bodies behind you are what the player is playing for, so this slot
// prints those instead. Same slot, same size — the mode changes the words, not the furniture.
ctx.fillText(G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
}
if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
// ...and NOT during a match: nothing on that court is killed for points, so the pips were both a
// lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel.
if (splat && !G.match && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
for (let k = 0; k < TUNE.killsPerPoint; k++) {
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
@@ -2052,6 +2086,7 @@ export function render() {
}
ctx.restore();
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
drawBlood(); // how many more hits you have left, beside the board — the vignette says "worse", not "how many"
if (G.mode === 'splatter' && !G.interlude && !G.drill) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
drawDrillHud(); // the triage readout — no-op unless a drill is running
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
@@ -2062,17 +2097,23 @@ export function render() {
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
// The announcement face, at 26 rather than the card's 30: this is the longest string in the game
// and at 30 it ran edge to edge on a 270px-wide portrait canvas.
// The match bled to death rather than being eaten, and it counts a different thing — see the
// matchBest block in killPlayer. Same three lines, its own words: the measure is bodies felled.
const m = G.match && G.match.felled !== undefined ? G.match : null;
ctx.font = B(26);
shadowText('GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad);
shadowText(m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad);
ctx.font = F(9);
shadowText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14, UI.cream);
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
shadowText(m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S')
+ ' · ' + (score.games[0] + score.games[1]) + ' GAMES'
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14, UI.cream);
if (m ? m.newBest : G.newBest) { // a fresh record crows; otherwise show the mark to beat
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4));
ctx.font = B(20);
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse));
} else if (G.best && G.best.games > 0) {
} else if (m ? m.best > 0 : (G.best && G.best.games > 0)) {
ctx.font = F(8);
shadowText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
shadowText(m ? 'BEST ' + m.best + ' FELLED'
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
}
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
if (G.eatenT > EATEN_RESTART) { ctx.font = F(8); shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm); }
+11
View File
@@ -302,6 +302,17 @@ export function killPlayer() {
// runGames/sets/splatT are all still intact at this point.
const b = endRun(G.runGames, G.sets[0], G.splatT);
G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets
// THE MATCH MEASURES SOMETHING ELSE. Blood-only scoring (model A) means a lost game costs nothing
// but the blood already spent, so "games survived" is not the achievement — OPPONENTS FELLED is.
// Kept on G.match (which survives to the death screen; restartRun is what clears it) with its own
// localStorage best, the pattern drillBest already set. Games ride along as the length of the run.
if (G.match) {
const felled = G.match.opp - 1;
let best = 0;
try { best = Number(localStorage.getItem('matchBest')) || 0;
if (felled > best) localStorage.setItem('matchBest', String(felled)); } catch {}
G.match.felled = felled; G.match.best = best; G.match.newBest = felled > best;
}
}
// One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
+21
View File
@@ -750,5 +750,26 @@ test('regicide: killing the body mid-game wins the game outright', () => {
assert.ok(oppOf(), 'and the next opponent is on');
});
test('the match measures OPPONENTS FELLED, with its own best', () => {
localStorage.removeItem('matchBest');
startMatch();
for (let k = 0; k < 8; k++) resolveInterlude('P'); // two games = two bodies down
assert.equal(G.match.opp, 3, 'on the third rung');
G.hp = 1; resolveInterlude('O');
assert.equal(G.mode, 'eaten', 'bled out');
assert.equal(G.match.felled, 2, 'the summary counts bodies, not games');
assert.ok(G.match.newBest, 'first run of the day is a record');
assert.equal(localStorage.getItem('matchBest'), '2', '...and it is written down');
// a shorter second run must NOT overwrite the mark, and must know what it is chasing
G.mode = 'splatter'; endMatch(); startMatch();
for (let k = 0; k < 4; k++) resolveInterlude('P');
G.hp = 1; resolveInterlude('O');
assert.equal(G.match.felled, 1);
assert.equal(G.match.best, 2, 'the mark to beat is shown');
assert.ok(!G.match.newBest, 'and one body does not beat two');
assert.equal(localStorage.getItem('matchBest'), '2', 'the record survives a worse run');
G.mode = 'splatter'; endMatch();
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report