Files
splatter-tennis/index.html
T
wuwiandClaude Opus 5 d5a069a90c The auto-run owns the legs in a rally, so the drag lets go of them
On touch, a drag in zombie tennis and classic still walked the player — and
that was not a harmless second option. A manual axis suppresses the assist, so
holding a drag traded a full-speed run to the intercept point for a 0.35x
shuffle aimed wherever the thumb went instead of where the ball will be.
Walking was strictly worse than standing still, which is most of "i tend to not
be able to reach the balls".

touchAxis is no longer written under rallyFeel. The drag keeps its one rally
meaning — released quickly it is the aimed swing — keys stay a deliberate
desktop override, and gore keeps its drag-walk, where the assist recovers depth
only and holding your own lane is the entire point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:09:19 +02:00

665 lines
46 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Splatter Tennis</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<!-- save-to-homescreen: a bloody tennis ball. iOS needs the apple-* tags (it ignores the
manifest's display/name); Android reads the manifest. -->
<link rel="icon" type="image/png" href="assets/favicon.png">
<link rel="apple-touch-icon" href="assets/icon-180.png">
<link rel="manifest" href="manifest.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<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
a day, so these three files are fetched once per release instead of revalidated on every cold
start. Bump alongside SPRV in render.js if a font file is ever replaced. */
@font-face { font-family: 'Silkscreen'; font-weight: 400; font-display: block;
src: url('assets/fonts/Silkscreen-Regular.ttf?v=86') format('truetype'); }
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
src: url('assets/fonts/Silkscreen-Bold.ttf?v=86') 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?v=86') 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. */
html { height: 100%; height: 100dvh; }
html, body { margin: 0; padding: 0; background: #10141c; overflow: hidden; }
/* invisible probe: env() is only readable from CSS, so park the insets on an element and let
render.js measure them. The canvas stays FULL-BLEED (the stands under the notch look good) —
it is the HUD that has to move, see SAFE in render.js. */
#safe { position: fixed; top: 0; left: 0; width: 0; height: 0; pointer-events: none; visibility: hidden;
padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); }
/* The canvas STRETCHES to the viewport and render.js derives its logical height from the box it
actually got. Sizing it the other way round (compute a scale from innerWidth/innerHeight, then set
explicit px) meant it was laid out against whatever innerHeight reported at load — before iOS
Safari's toolbars had settled — and it stayed a strip short of the bottom forever after.
100dvh is the dynamic viewport height, so it tracks those toolbars. */
/* TOP inset only. The status bar is opaque UI and the scoreboard must clear it, so the canvas starts
below it — but the home indicator is a slim translucent bar, so the canvas runs all the way to the
PHYSICAL bottom edge and the court art fills it. Only the weapon RACK lifts clear of the indicator,
via SAFE.bottom in render.js. Reserving the bottom inset here instead just left a dead band.
100dvh is the dynamic viewport height, so it tracks iOS Safari's toolbars appearing/hiding. */
canvas { image-rendering: pixelated; display: block; position: fixed; touch-action: none;
top: env(safe-area-inset-top, 0px);
left: env(safe-area-inset-left, 0px);
width: calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
height: calc(100vh - env(safe-area-inset-top, 0px));
height: calc(100dvh - env(safe-area-inset-top, 0px)); }
/* ...but NOT installed. Launched from the homescreen, WebKit does not initialise the DYNAMIC
viewport until the geometry is "exercised" — 100dvh stays provisional until you rotate the
phone, which is exactly the bug. Standalone has no toolbars, so 100vh is both correct and
stable from the first frame; dvh only exists for the browser's disappearing toolbars. */
@media (display-mode: standalone) {
html { height: 100vh; }
canvas { height: calc(100vh - env(safe-area-inset-top, 0px)); }
}
/* desktop key hint — top-right, shifted left of the quit chip that owns the corner now */
#hint { position: fixed; top: 6px; right: 52px; text-align: right;
font: 400 10px Silkscreen, monospace; color: #6b7688; }
/* THE DOOR OUT (Oliver: every tennis screen lacked an exit — force-closing the app was the only
way back to the menu). A quiet chip during play; tapping it freezes the world and asks. */
/* THE CORNER IS THE DOOR. It used to hang a row below, under the BLOOD strip, which is where a
thumb never looks for an exit — the BLOOD panel steps aside for it instead (drawBlood in
render.js reserves this chip's width). Same recessed-panel language as the canvas HUD:
dark green field, a hairline of the board's own highlight, a 1px inner bevel — and it goes
crimson while pressed, because red is reserved for the thing you actually came to press. */
/* --hud is the canvas HUD's own 6-logical-px margin, converted to CSS px by render.js, so the
chip's top edge lands on the same line as the scoreboard's and the BLOOD panel's. */
#quit { position: fixed; z-index: 5;
top: calc(env(safe-area-inset-top, 0px) + var(--hud, 8px));
right: calc(env(safe-area-inset-right, 0px) + var(--hud, 8px)); }
/* ...and it is the BLOOD panel's own height: that panel is 24 logical px, which is four --hud. */
#quit button { width: calc(var(--hud, 8px) * 4); height: calc(var(--hud, 8px) * 4);
border-radius: 3px; display: grid; place-items: center;
background: rgba(8,26,18,0.82); border: 1px solid rgba(63,149,96,0.35);
box-shadow: inset 0 1px 0 rgba(242,234,214,0.10), 0 1px 2px rgba(0,0,0,0.45);
/* a plain X, not ✕: Silkscreen has no U+2715, so the dingbat fell back to the
system font and drew a smooth glyph inside a pixel HUD */
color: var(--cream); font: 700 14px Silkscreen, monospace; line-height: 1;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#quit button:active { background: var(--red); border-color: var(--bad); color: #f2ead6; }
#qAsk { position: fixed; inset: 0; z-index: 6; background: rgba(0,0,0,0.55);
display: flex; align-items: center; justify-content: center; }
#quit[hidden], #qAsk[hidden] { display: none; }
.qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px;
width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; }
.qBox p { margin: 0 0 6px; font: 400 12px Silkscreen, monospace; color: var(--cream);
text-align: center; letter-spacing: 1px; }
@media (pointer: coarse) { #hint { display: none; } }
/* ---- THE MENU PALETTE. The canvas HUD keeps its own blue-grey and gold; the menus are
deliberately a different room — dark court green, one acid accent, and one crimson reserved for
the thing you actually came here to press. Five names so a repaint is five edits, not fifty. */
/* COOL, not olive. The first pass sat around hue 80 — yellow-green — and read loud and warm next
to the art. These are the same idea rotated towards blue-green and desaturated: the field is
nearly neutral, the borders are quiet, and the acid accent is the only thing allowed to be
bright, so it still means "this is the number / this is selected". */
:root { --ink:#0e1714; --panel:rgba(42,62,54,0.42); --line:#2c4238; --acid:#bcd455;
--cream:#ccd7ca; --dim:#6c7f74; --red:#a4231f; --bad:#cf5a4e;
/* the same green as --panel over --ink, but OPAQUE: the pinned header and footer have
content scrolling underneath them and a translucent bar shows it through */
--solid:#16211d; }
/* touch-action is pan-y, not none: the BODY COUNT list is taller than a phone and has to scroll.
align-content is `safe center` rather than place-items — plain centring on a grid whose item
overflows pushes the top of the item out of the scrollport, where nothing can reach it. */
#intro { position: fixed; inset: 0; z-index: 10; touch-action: pan-y; background: var(--ink);
display: grid; grid-template-columns: 1fr; justify-items: center;
align-content: safe center; overflow-y: auto; overscroll-behavior: contain; }
/* the SPLASH sits on the court; the other stages are lists and want a flat field behind them.
The art is dimmed here rather than baked dark, so the same file can be reused at another level. */
#intro[data-stage="splash"] {
/* Three stops, not two. The logo needs a dark, quiet band behind it and the blood needs to be
READABLE, and those pull opposite ways — so the scrim is heaviest at the top, lifts over the
court where the stain is, and comes back down only far enough to seat the buttons. */
background: linear-gradient(rgba(12,20,17,0.60) 0%, rgba(12,20,17,0.30) 46%,
rgba(12,20,17,0.58) 100%),
url('assets/menu_bg.png?v=86') 50% 42% / cover no-repeat var(--ink);
image-rendering: pixelated; }
/* THE BACK-ROOM HOTSPOT: five taps here open it (main.js). Top-left CORNER, not the logo and not
the screen — a hidden door has to be somewhere you do not put your thumb by accident, and the
whole splash was reachable by every stray tap on the way to PLAY MATCH. Invisible on purpose,
and a child of #splash so it exists on no other stage. */
#hot { position: fixed; top: 0; left: 0; width: 26vw; max-width: 150px; height: 15vh;
max-height: 130px; z-index: 4; }
#intro .card { font: 400 12px Silkscreen, monospace; color: var(--cream); text-align: center;
line-height: 1.9; padding: 16px 14px 20px; width: min(370px, 94vw);
animation: drop 0.5s ease-out; }
@keyframes drop { from { transform: translateY(-14px); opacity: 0; } }
/* EVERY SCREEN FILLS THE VIEWPORT, and every screen is the same three bands: something pinned to
the top, something pinned to the bottom, and whatever is between them taking the slack. The
card is a full-height flex column, so `margin-top:auto` on the footer drops it to the bottom
when the content is short and `position:sticky` keeps it there once the content is long enough
to scroll. Centring the card instead left the buttons floating in the middle of a tall phone. */
#intro { align-content: stretch; }
#intro .card { display: flex; flex-direction: column; min-height: 100dvh; padding: 0 14px; }
/* the slack band: the title sits in the middle of whatever is left over */
.mid { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 12px 0; }
/* the logo is generated pixel art (assets/logo.png), not stacked CSS text */
#intro .logo { display: block; width: min(300px, 74vw); height: auto; margin: 0 auto 6px;
image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
/* the slogan carries most of the gap between the logo's blood and the first button */
#intro .slogan { margin: 8px 0 20px; font-size: 11px; color: #a8a09e; letter-spacing: 0.5px; }
#intro .slogan b { color: #c9564a; }
/* which build is served — filled in by main.js, empty (and invisible) if the header is missing */
#build { margin: 14px 0 0; font-size: 9px; color: #4a5a52; letter-spacing: 0.5px; }
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
#intro[data-stage="setup"] #setup,
#intro[data-stage="mode"] #stmode,
#intro[data-stage="modesub"] #stsub,
#intro[data-stage="stats"] #stats,
#intro[data-stage="ranks"] #ranks,
#intro[data-stage="secret"] #secret,
#intro[data-stage="bosses"] #bosses,
#intro[data-stage="bracket"] #bracket,
#intro[data-stage="vs"] #vs,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; }
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
display:grid above would otherwise win over the hidden attribute */
#intro[hidden] { display: none; }
/* Screen titles and the primary button are BANGERS — the same announcement face the canvas uses
for SET and CHAPTER. A screen title is a thing that shouts. Everything that is a NUMBER or a
label stays Silkscreen, so the two registers keep meaning what they have always meant. */
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
#intro h3 { display: flex; justify-content: space-between; margin: 20px 0 9px;
font: 400 10px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px;
text-align: left; }
/* header strip: back chevron + title. Every screen but the splash wears one, pinned to the top. */
.hdr { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 10px;
padding: 10px 12px; margin: 0 -14px; border-bottom: 2px solid var(--line);
background: var(--solid);
padding-top: max(10px, env(safe-area-inset-top)); }
.hdr h2 { padding-left: 2px; }
.chev { width: 34px; height: 34px; flex: none; background: none; border: 2px solid var(--line);
color: var(--acid); font: 400 20px Bangers, cursive; line-height: 1; cursor: pointer;
-webkit-tap-highlight-color: transparent; }
/* the BODY COUNT headline row: three boxed figures sharing one frame */
.figs { display: flex; border: 2px solid var(--line); background: var(--panel); }
.fig { flex: 1; min-width: 0; padding: 7px 6px; text-align: center; }
.fig + .fig { border-left: 2px solid var(--line); }
.fig b { display: block; font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.fig i { display: block; font: 700 18px Silkscreen, monospace; color: var(--acid);
font-style: normal; letter-spacing: 1px; }
.fig.bad i { color: var(--bad); }
/* one button look for every menu screen */
/* OPAQUE, not the translucent panel fill. On the list screens it makes no visible difference —
they sit on flat ink — but on the splash a see-through button lets the court's white lines run
straight through the lettering, and the button stops reading as a button. */
.btn { display: block; width: 100%; margin: 0 auto 10px; padding: 12px 10px;
background: var(--solid); border: 2px solid var(--line); color: var(--cream);
font: 400 13px Silkscreen, monospace; letter-spacing: 1px; cursor: pointer;
-webkit-tap-highlight-color: transparent; }
/* The generated button plates are gone with the tutorial. The primary is a flat crimson block
now: it survives being resized, which a fixed-size plate crop never did, and the mockup's
hierarchy is carried by colour rather than by art. */
.btn.big { padding: 13px 10px 11px; background: var(--red); border-color: #69201c; color: #f6e7dc;
font: 400 26px Bangers, cursive; letter-spacing: 2px; line-height: 1.15; }
.two { display: flex; gap: 8px; }
.two .btn { margin-bottom: 0; }
.btn.sec i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace;
font-style: normal; color: var(--dim); letter-spacing: 0.5px; }
/* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap
and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */
#who { display: flex; gap: 12px; align-items: flex-start; }
#whoCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
/* The live player. `position: static` is not decoration: the bare `canvas` rule above is the COURT
— fixed, full-bleed, 100dvh — and it catches any canvas in the document, so this one flew to the
top-left corner of the screen until it was told otherwise. Every geometry property that rule
sets has to be undone here. The buffer is the sprite's own 96x224, so width follows height. */
#look { position: static; display: block; flex: none; margin: 0;
width: auto; height: 176px; touch-action: auto; image-rendering: pixelated; }
/* the tour is now TWO WORDS, not two portraits — the live player next to them is the picture, and
a second drawing of the same man (front-facing, and deaf to every colour on this screen) was
both a duplicate and the reason the court list started below the fold */
#tours { display: flex; gap: 7px; }
#tours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
color: var(--dim); font: 400 10px Silkscreen, monospace; letter-spacing: 1px;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#tours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
/* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is
the whole UI — the pick has no meters and no art to show, and a labelled tile per colour would
be five times the screen for a choice you make by looking at the colour. */
.swlab { display: flex; justify-content: space-between; margin-top: 2px; text-decoration: none;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1.5px; }
.swlab i { font-style: normal; color: var(--cream); }
.swrow { display: flex; gap: 6px; }
.swrow button { flex: 1; aspect-ratio: 1; min-width: 0; padding: 0; border: 2px solid var(--line);
cursor: pointer; -webkit-tap-highlight-color: transparent; }
.swrow button.on { border-color: var(--acid); box-shadow: inset 0 0 0 2px var(--ink); }
/* THE BOSS PICKER (back room). Two columns of portraits, because you pick a boss by looking at
him — the tile is mostly art. Same plate as the tour tiles above; the grid is what makes ten
of them fit on a phone. */
#bossList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
#bossList button { display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 8px 6px 10px; background: var(--panel); border: 2px solid var(--line);
color: inherit; font: inherit; cursor: pointer;
-webkit-tap-highlight-color: transparent; }
#bossList img { height: 78px; width: auto; display: block; image-rendering: pixelated; }
#bossList b { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
text-align: center; line-height: 1.35; }
#bossList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
letter-spacing: 1px; }
/* THE DRAW. Four rungs read top-down as a ladder, because that is what the mode is — the bracket
shape a real draw is printed in needs width a phone has not got, and half of it would be blank
anyway (you only ever play one side of it). The rows spin their portrait and their name while
the draw is being made, and .set is what a landed one wears: the placeholder look is on the
ROW, so a slot that has stopped is obvious at a glance and START stays out of reach until the
last of them lands. The final wears .set from the first frame — it was never in doubt. */
#cupRows { display: flex; flex-direction: column; gap: 8px; }
.cupRow { display: grid; grid-template-columns: 54px 1fr auto; grid-template-rows: auto auto;
align-items: center; column-gap: 10px; row-gap: 2px;
padding: 7px 10px; background: var(--panel); border: 2px solid var(--line);
/* the connector: every round but the first hangs off the one above it */
position: relative; }
.cupRow + .cupRow::before { content: ''; position: absolute; left: 35px; top: -10px; width: 2px;
height: 10px; background: var(--line); }
.cupRow img { grid-column: 1; grid-row: 1 / span 2; width: 54px; height: 54px;
object-fit: contain; object-position: center; image-rendering: pixelated;
/* mid-spin the faces flicker past unreadable; dimming them says "still rolling"
without a second element to keep in sync */
opacity: 0.5; filter: grayscale(0.7); }
.cupRow u { grid-column: 2; grid-row: 1; font: 400 8px Silkscreen, monospace; color: var(--dim);
letter-spacing: 1.5px; text-decoration: none; text-align: left; }
.cupRow b { grid-column: 2; grid-row: 2; font: 400 11px Silkscreen, monospace; color: var(--dim);
letter-spacing: 0.5px; line-height: 1.35; text-align: left; }
.cupRow i { grid-column: 3; grid-row: 1 / span 2; font: 400 8px Silkscreen, monospace;
font-style: normal; color: var(--dim); letter-spacing: 1px; white-space: nowrap; }
.cupRow.set img { opacity: 1; filter: none; }
.cupRow.set b { color: var(--cream); }
.cupRow.fin { border-color: var(--red); }
.cupRow.fin.set b { color: var(--acid); }
/* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */
.btn[disabled] { opacity: 0.32; cursor: default; }
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
/* MODE posters are pure BUTTONS, not selectors (Oliver: the remembered highlight made each one
"a checkbox and a button combined"). Every card is full-bright and equal because every card is
a live answer to the header's question; tapping answers and advances. The only memory shown is
the LAST tag — an indicator, not a selection. A border that means "SERVE UP will use this"
exists solely on the final screen, where a confirm button actually follows. */
#modeList button.mode, #modeSub button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px;
margin: 0 0 8px; padding: 7px 9px; background-size: cover; background-position: center bottom;
image-rendering: pixelated; border: 2px solid var(--line); color: inherit; font: inherit;
text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
position: relative; }
.last { position: absolute; top: 7px; right: 9px; font: 400 8px Silkscreen, monospace;
color: var(--acid); background: rgba(0,0,0,0.72); padding: 3px 6px;
letter-spacing: 1px; border: 1px solid rgba(188,212,85,0.5); }
#modeList b, #modeSub b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); }
/* the subtitle carries the mode's one-line rules over busy art, so it gets WHITE at full
opacity, a size up from the court rows' 9px, and a solid black underlay — "barely readable"
at cream-on-crowd was the report from the first live look */
#modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0;
text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; }
/* THE SECOND STEP (config.MENU) has its own SCREEN in the wizard: sub-mode cards are
full-height posters centred on their scene, and the difficulty chips are plain acid-outline
buttons stacked tall — art for EASY/MEDIUM/HARD would be forced, and the chip reads faster. */
/* alone on their own screens, the posters get room to be posters — and the first card clears
the header by the same breath the h3 rows used to give the old single page */
#stmode #modeList, #stsub #modeSub { margin-top: 12px; }
#stmode button.mode, #modeSub button.mode { min-height: 148px; }
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; }
#modeSub button.mode { background-position: center; }
#modeSub button.chip { flex: 1 1 100%; padding: 22px 0; background: var(--panel); border: 2px solid var(--line);
color: var(--cream); font: 400 13px Silkscreen, monospace; letter-spacing: 2px;
cursor: pointer; -webkit-tap-highlight-color: transparent; position: relative; }
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
margin: 0 0 8px; padding: 7px; background: var(--panel);
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
#courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none;
object-fit: cover; }
#courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; }
#courtList b, #modeList b, #modeSub b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
#courtList i, #modeList i, #modeSub i { display: block; margin: 3px 0 6px; font: 400 9px Silkscreen, monospace; color: var(--dim); font-style: normal; }
/* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot
drift away from what the ball actually does on it. */
.meter { display: flex; align-items: center; gap: 6px; margin-top: 4px;
font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.meter u { flex: none; width: 46px; text-decoration: none; }
.meter s { flex: 1; height: 5px; background: rgba(0,0,0,0.45); text-decoration: none; display: block; }
.meter s > i { display: block; height: 100%; background: var(--acid); }
.meter.b s > i { background: #4e8fa6; }
/* the commit strip. #intro is the scrollport, so sticky resolves against it and the button stays
reachable however long the list above grows. */
/* margin-top:auto pins it to the BOTTOM of a short screen; sticky keeps it there on a long one */
.foot { position: sticky; bottom: 0; z-index: 2; margin: auto -14px 0; padding: 10px 14px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
border-top: 2px solid var(--line); background: var(--solid); }
.foot .btn { margin-bottom: 0; }
/* the splash's bottom band: no bar, no rule — the court art has to keep running under it */
.foot.bare { border: 0; background: none; }
.foot.bare .btn.big { margin-bottom: 10px; }
/* the boot bar wears the stats screen's kill bar, doing duty as a loading bar. While #splash is
.busy it stands in the buttons' place — same band, same height, so nothing jumps when it goes. */
#boot { display: none; margin-bottom: 10px; }
#boot s { display: block; height: 6px; background: rgba(0,0,0,0.45); text-decoration: none; }
#boot s > i { display: block; width: 0; height: 100%; background: var(--acid);
transition: width 0.15s linear; }
#boot u { display: block; margin-top: 7px; text-align: center; text-decoration: none;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
#splash.busy #boot { display: block; }
#splash.busy .btn, #splash.busy .two, #splash.busy #build { display: none; }
#sSum, #brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
.tabs button { flex: 1; padding: 11px 2px; background: var(--panel); border: 2px solid var(--line);
color: var(--dim); font: 400 10px Silkscreen, monospace; letter-spacing: 1px;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
.tabs button.on { border-color: var(--acid); color: var(--acid); }
/* one opponent. The sprite is the walk frame the game already draws, sat on its own baseline. */
.zrow { display: flex; align-items: center; gap: 11px; padding: 9px 10px; margin-bottom: 6px;
border: 2px solid var(--line); background: var(--panel); text-align: left; }
.zrow.boss { border-color: #6e2a24; background: rgba(110,42,36,0.22); }
.zrow img { width: 48px; height: 62px; object-fit: contain; object-position: bottom;
image-rendering: pixelated; flex: none; }
.zb { flex: 1; min-width: 0; display: grid; grid-template-columns: 1fr auto; gap: 0 8px;
line-height: 1.4; }
.zb b { font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zb > span { font: 700 15px Silkscreen, monospace; color: var(--acid); text-align: right; }
.zrow.boss .zb > span { color: var(--bad); }
.pb { grid-column: 1 / -1; height: 6px; margin-top: 4px; background: rgba(0,0,0,0.45); }
.pb s { display: block; height: 100%; background: var(--acid); text-decoration: none; }
.zrow.boss .pb s { background: var(--bad); }
.zb i { grid-column: 1 / -1; margin-top: 4px; font: 400 9px Silkscreen, monospace;
font-style: normal; color: var(--dim); letter-spacing: 1px; }
#stW { display: flex; flex-wrap: wrap; gap: 6px; }
.wtile { flex: 1 1 62px; max-width: 84px; padding: 8px 2px; border: 2px solid var(--line);
background: var(--panel); }
.wtile img { width: 34px; height: 34px; object-fit: contain; display: block; margin: 0 auto 2px;
image-rendering: pixelated; }
.wtile b { display: block; font: 400 11px Silkscreen, monospace; color: var(--acid); }
.none { margin: 4px 0 0; font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px;
text-align: left; }
#stFoot { display: flex; justify-content: space-between; align-items: baseline;
border-top: 2px solid var(--line); }
#stFoot b { font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
#stFoot i { font: 700 16px Silkscreen, monospace; color: var(--acid); font-style: normal; }
.rkrow { display: flex; align-items: center; gap: 10px; padding: 8px 10px; margin-bottom: 5px;
border: 2px solid var(--line); background: var(--panel); text-align: left; }
.rkrow.me { border-color: var(--acid); background: rgba(188,212,85,0.11); }
.rkrow b { flex: none; width: 68px; font: 700 12px Silkscreen, monospace; color: var(--dim); }
.rkrow.me b { color: var(--acid); }
.rkrow span { flex: 1; min-width: 0; font: 400 11px Silkscreen, monospace; color: var(--cream);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rkrow i { font: 400 11px Silkscreen, monospace; font-style: normal; color: var(--dim); }
/* ---- typed fields. Two screens ask for typing — the name prompt and the netplay lobby — and
both want the same field: big, centred, acid on the panel, no rounded corners anywhere near it.
One class, two size overrides. */
.cent { text-align: center; }
.tin { display: block; width: 100%; box-sizing: border-box; margin: 0 auto;
padding: 12px 10px; background: var(--solid); border: 2px solid var(--line);
border-radius: 0; color: var(--acid); font: 700 16px Silkscreen, monospace;
letter-spacing: 3px; text-align: center; text-transform: uppercase;
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
.tin:focus { outline: none; border-color: var(--acid); }
.tin::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 12px; }
#newbest h2 { font-size: 30px; margin-bottom: 6px; }
#nbScore { margin: 0 0 22px; font: 400 11px Silkscreen, monospace; color: var(--dim);
letter-spacing: 1px; }
#nbScore b { color: var(--acid); font-weight: 400; }
#nbName { margin-bottom: 10px; padding: 14px 10px; font-size: 20px; }
#nbName::placeholder { font-size: 14px; }
/* ---- THE NETPLAY LOBBY. Everything the mode needs before there is a court: who you are, the
two ways to host, and the friend's code. The room you are waiting in replaces the picker in
place (#vsWait), so the code and the SHARE button stand where the buttons were. */
#vsErr:empty { display: none; }
#vsErr { margin: 10px 0 0; padding: 8px; border: 2px solid #6e2a24; background: rgba(110,42,36,0.22);
font: 400 10px Silkscreen, monospace; color: var(--bad); letter-spacing: 1px;
text-align: center; }
#vs .btn { margin-bottom: 8px; }
/* the room takes the whole screen the picker had: the code floats in the middle band and CANCEL
sits under your thumb, same three-band anchoring as every other screen */
#vsWait { display: flex; flex-direction: column; flex: 1; }
#vsWait[hidden] { display: none; }
.joinrow { display: flex; gap: 8px; align-items: stretch; }
.joinrow .tin { flex: 1; letter-spacing: 8px; }
/* JOIN is the act, not a label beside a box: the crimson plate every primary button in the game
wears, and NO padding of its own — the row stretches it to exactly the field's height, which
its own 13px padding could only ever approximate. */
/* #vs .btn's margin-bottom outranks a bare .joinrow .btn — and a margin on a stretched flex item
is exactly the 8px the button was coming up short by */
#vs .joinrow .btn { flex: none; width: 104px; margin: 0; padding: 0 6px; background: var(--red);
border-color: #69201c; color: #f6e7dc; font: 400 22px Bangers, cursive;
letter-spacing: 2px; }
/* the code is the biggest thing on the screen — it is read aloud across a room as often as it is
shared, and a four-letter code you have to squint at is the whole friction being removed */
#vsBig { margin: 4px 0 20px; font: 700 42px Silkscreen, monospace; color: var(--acid);
letter-spacing: 10px; text-indent: 10px; }
#vsMsg { margin-top: 12px; }
/* the h3 rows are label-left/hint-right everywhere else; this one has no hint and sits over a
centred code, so it centres too (text-align cannot reach it — the row is a flex box) */
#vsWHdr { justify-content: center; }
</style>
</head>
<body>
<canvas id="c" width="480" height="270"></canvas>
<div id="safe"></div>
<div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div>
<div id="quit" hidden><button id="qChip">X</button></div>
<div id="qAsk" hidden>
<div class="qBox">
<p>LEAVE THE MATCH?</p>
<button class="btn big" id="qYes">BACK TO THE MENU</button>
<button class="btn sec" id="qNo">PLAY ON</button>
</div>
</div>
<div id="intro" data-stage="splash">
<div class="card">
<!-- The title screen IS the menu. A separate menu stage behind it was the same logo over the
same backdrop with two buttons, so it read as the title screen failing to advance. -->
<!-- three bands: the figures at the top, the title floating in the middle, the buttons under
your thumb. Same anchoring as every other screen. -->
<div id="splash">
<div id="hot"></div>
<div class="mid">
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
</div>
<div class="foot bare">
<!-- THE BOOT BAR. Every sprite is requested the moment render.js is imported; this only
shows how far that got, and stands where the buttons will be until it is done — a menu
you can press before the sheets have landed is how you reach the stats screen with half
the zombies missing. Swapped by #splash.busy, see main.js. -->
<div id="boot"><s><i></i></s><u>LOADING</u></div>
<button class="btn big" id="mStart">PLAY MATCH</button>
<div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
<button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button>
</div>
<p id="build"></p>
</div>
</div>
<!-- tour tiles and court rows are both built from tables in main.js (config.TOURS / config.COURTS):
adding either is a table row, never markup -->
<!-- THE SETUP IS A WIZARD now (Oliver: one screen of posters + chips + tiles + meters was
"really overwhelming"). Three stages, tap advances: the mode posters, then the mode's own
second step when it has one (classic: difficulty, zombie tennis: arcade or the cup), then
who-and-where with SERVE UP. Every step comes up pre-selected from last time, so a
returning player is three taps from serving. -->
<div id="stmode">
<div class="hdr"><button class="chev" id="mBack">&lsaquo;</button><h2>PICK A GAME</h2></div>
<div id="modeList"></div>
</div>
<div id="stsub">
<div class="hdr"><button class="chev" id="subBack">&lsaquo;</button><h2 id="subTitle">DIFFICULTY</h2></div>
<div id="modeSub"></div>
</div>
<div id="setup">
<div class="hdr"><button class="chev" id="sBack">&lsaquo;</button><h2>MATCH SETUP</h2></div>
<!-- ONE PLAYER BLOCK. It was four stacked sections — two big portrait tiles, then the live
player, then a kit row, then a skin row — which said the same thing twice (the tiles were
a second, FRONT-FACING drawing of a man who does not react to any of the colours) and ran
the court off the bottom of the phone. The tiles are gone: the live player IS the picture,
so who-you-are shrinks to two words, and every control that changes him stands beside him
where you can watch it land. Kit and skin rows are built in main.js from the tables. -->
<h3>01 · PLAYER</h3>
<div id="who">
<canvas id="look" width="96" height="224"></canvas>
<div id="whoCtl">
<div id="tours"></div>
<u class="swlab">KIT<i id="kitName"></i></u>
<div id="kitRow" class="swrow"></div>
<u class="swlab">SKIN<i id="skinName"></i></u>
<div id="skinRow" class="swrow"></div>
</div>
</div>
<h3>02 · COURT</h3>
<div id="courtList"></div>
<div class="foot">
<p id="sSum"></p>
<button class="btn big" id="cStart">SERVE UP</button>
</div>
</div>
<!-- THE DRAW (cup mode only). SERVE UP lands here instead of on a court: the four rounds roll
through their own band of the ladder and stop, and what you watched land is what walks on
(main.js hands the settled rungs to startMatch). The rows are built in main.js from
config.CUP_ROUNDS, so a round added there needs no markup. -->
<div id="bracket">
<div class="hdr"><button class="chev" id="brBack">&lsaquo;</button><h2>THE DRAW</h2></div>
<h3>FOUR ROUNDS<span id="brState">DRAWING…</span></h3>
<div id="cupRows"></div>
<div class="foot">
<p id="brSum"></p>
<button class="btn big" id="cupGo" disabled>ENTER THE DRAW</button>
</div>
</div>
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
the host's one decision, and the friend's code. #vsWait replaces the picker in place once
a socket is open, so the room code and its SHARE button stand where the buttons were and
the game stays parked behind this overlay until the room fills (main.js, THE LOBBY). -->
<div id="vs">
<div class="hdr"><button class="chev" id="vsBack">&lsaquo;</button><h2>VS A FRIEND</h2></div>
<div id="vsPick">
<h3>01 · YOUR NAME<span>ON THEIR SCOREBOARD</span></h3>
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<h3>02 · HOST A MATCH<span>THEY COME TO YOU</span></h3>
<button class="btn" id="vsHostC">CLASSIC TENNIS</button>
<button class="btn" id="vsHostD">DEATH MATCH</button>
<h3>OR JOIN THEIRS<span>WITH THEIR CODE</span></h3>
<div class="joinrow">
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<button class="btn" id="vsJoin">JOIN</button>
</div>
<p id="vsErr"></p>
</div>
<div id="vsWait" hidden>
<div class="mid cent">
<h3 id="vsWHdr" class="cent">ROOM CODE</h3>
<p id="vsBig">····</p>
<button class="btn big" id="vsShare">SEND THE CODE</button>
<p class="none cent" id="vsMsg">WAITING FOR THEM TO JOIN</p>
</div>
<div class="foot bare"><button class="btn" id="vsCancel">CANCEL</button></div>
</div>
</div>
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
<div id="stats">
<div class="hdr"><button class="chev" id="stBack">&lsaquo;</button><h2 id="stTitle">BODY COUNT</h2></div>
<!-- WHICH MODE, then which period. The three modes do not measure the same thing (see the
record-shape note in stats.endRun), so this strip changes the whole screen under it —
the title, the three figures, the roster's heading and the footer. -->
<div class="tabs" id="stModes">
<button class="on" data-m="g">SPLATTER</button>
<button data-m="z">TENNIS</button>
<button data-m="c">CLASSIC</button>
</div>
<div class="tabs" id="stTabs">
<button class="on" data-p="all">ALL TIME</button>
<button data-p="last">LAST RUN</button>
<button data-p="7d">7 DAYS</button>
</div>
<div class="figs" id="stFigs"></div>
<h3 id="stZHdr">BY OPPONENT<span id="stPeriod"></span></h3>
<div id="stZ"></div>
<h3 id="stWHdr">BY WEAPON</h3>
<div id="stW"></div>
<div class="foot" id="stFoot"></div>
</div>
<!-- THREE BOARDS, one per game mode — the three do not share a currency, so they cannot share
a ranking. Same tab strip as the stats screen's, and stats.drawRanks rewrites the column
heading with the tab, because the number means something different on each one. -->
<div id="ranks">
<div class="hdr"><button class="chev" id="rkBack">&lsaquo;</button><h2>WORLD BOARD</h2></div>
<div class="tabs" id="rkModes">
<button class="on" data-m="g">SPLATTER</button>
<button data-m="z">TENNIS</button>
<button data-m="c">CLASSIC</button>
</div>
<h3 id="rkHdr">BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
<div id="rkList"></div>
</div>
<!-- THE BACK ROOM. Five taps on the logo (main.js) and nothing else — no button anywhere hints
at it, which is the whole point: it replaced a triple-tap on the in-play scoreboard that
players kept tripping by accident. -->
<div id="secret">
<div class="hdr"><button class="chev" id="scBack">&lsaquo;</button><h2>BACK ROOM</h2></div>
<div class="foot bare">
<button class="btn big" id="scWeapons">WEAPON TEST</button>
<button class="btn big" id="scBosses">BOSS TEST</button>
<button class="btn big" id="scDrill">DRILL TEST</button>
<button class="btn big" id="scMatch">MATCH TEST</button>
<button class="btn big" id="scBench">SPRITE BENCH</button>
</div>
</div>
<!-- one tile per chapter boss, built from config.bossArt in main.js: adding a design to the
roster puts it on this screen and never touches this markup -->
<div id="bosses">
<div class="hdr"><button class="chev" id="bsBack">&lsaquo;</button><h2>BOSS TEST</h2></div>
<h3>PICK A CHAPTER<span>FULL ARSENAL</span></h3>
<div id="bossList"></div>
</div>
<!-- shown only when a run beats your own best kill count — see main.js askName -->
<div id="newbest">
<div class="mid">
<h2 class="cent">NEW PERSONAL BEST</h2>
<p id="nbScore"></p>
<input id="nbName" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<p class="none cent" id="nbMsg">PUT IT ON THE WORLD BOARD.</p>
</div>
<div class="foot bare">
<button class="btn big" id="nbSend">SIGN THE BOARD</button>
<button class="btn" id="nbSkip">NOT THIS TIME</button>
</div>
</div>
</div>
</div>
<script type="module" src="src/main.js?v=282"></script>
</body>
</html>