Files
splatter-tennis/index.html
T
wuwiandClaude Fable 5 87833db095 The embers actually show: off the shield's top edge, not across it
They were running the whole time (0.85 opacity, measured) — they were just
flying ACROSS the artwork, where a 3px gold speck disappears into a pixel
shield. They lift off the top edge into the dark above it now, seven per badge
on a shorter delay window: spread over 2.6s the shield stood empty half the
time, which is what "ich sehe kein glimmen" was looking at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:54:31 +02:00

1084 lines
78 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>
// (`Capacitor`: the SHELL is a prod build whatever its origin says — inside the app the hostname is
// `localhost`, which would otherwise put the staging face on the shipped game.)
if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNativePlatform?.()) {
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'); }
/* ...and the UPRIGHT display face (2026-08-31). Bangers is drawn slanted — there is no upright
cut of it, which is also why the chevrons are Silkscreen — so everything that NAMES rather
than announces needed a third voice. Bungee held it for an afternoon; JERSEY 15 took it in
the menu-text-reduction handoff, because it sits on the same pixel grid as Silkscreen (it
reads as one family with the sprite art) and runs proportionally lighter, where Bungee was
single-weight and blocky. SIL OFL, licence beside it, SUBSET to the characters this game
actually sets — 9 KB; the pyftsubset command is in PROJECT.md. Sizes run ~+40% against the
Bungee values: Jersey sits lower in its box. */
@font-face { font-family: 'Jersey 15'; font-weight: 400; font-display: block;
src: url('assets/fonts/Jersey15-Subset.ttf?v=87') 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);
/* the same drop shadow the canvas HUD carries (render.hudShadow): the chip sits on
the crowd like the scoreboard does, and half the overlay lifting off the
backdrop while the other half does not is worse than neither. */
box-shadow: inset 0 1px 0 rgba(242,234,214,0.10), 3px 3px 3px rgba(0,0,0,0.68);
/* 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; }
/* it is a QUESTION, and a question calls — the one Bangers line on this overlay */
.qBox p { margin: 0 0 10px; font: 400 20px Bangers, cursive; color: var(--cream);
text-align: center; letter-spacing: 1px; }
@media (pointer: coarse) { #hint { display: none; } }
/* ...and it is UI like any other: gone while the splash is still loading (the keyboard hints
live outside #intro, so this reaches them through the ancestor) */
body:has(#splash.busy) #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, 100vw - 28px); /* CONTENT width (content-box) beside 14px+14px padding:
94vw put the card 5-28px past the viewport on every phone (0.94w+28 > w below
466px) and #intro's overflow-y:auto computes overflow-x to auto — that was the
sliver of horizontal scroll Oliver caught on the splash */
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, #vs, #train, #player, #promo, #card { 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="train"] #train,
#intro[data-stage="vs"] #vs,
#intro[data-stage="player"] #player,
#intro[data-stage="promo"] #promo,
#intro[data-stage="card"] #card,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1; }
/* ...and every screen DEALS ITS BANDS IN (2026-08-31, Oliver: "wäre natürlich auch toll für die
untermenüs"). The panel used to drop in as one slab; now its header, its content and its
footer arrive one after the other on the same overshoot the splash and the canvas modals use.
The children of the stage, not the stage itself — a slab that drops says "a screen appeared",
a deal says "the machine is laying this out for you". The splash keeps its own finer stagger
below (its two bands would otherwise fly as two lumps), hence the :not. */
#intro.deal > .card > div:not(#splash) > * {
animation: dealIn 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
#intro.deal > .card > div:not(#splash) > *:nth-child(1) { animation-delay: 0.02s; }
#intro.deal > .card > div:not(#splash) > *:nth-child(2) { animation-delay: 0.09s; }
#intro.deal > .card > div:not(#splash) > *:nth-child(3) { animation-delay: 0.16s; }
#intro.deal > .card > div:not(#splash) > *:nth-child(4) { animation-delay: 0.23s; }
#intro.deal > .card > div:not(#splash) > *:nth-child(n+5) { animation-delay: 0.30s; }
/* the poster wall deals its ROOMS, not one lump: the list is the whole screen there, so letting
it arrive as a single band wastes the entrance. The list itself stops animating — a moving
child inside a moving parent starts at twice the offset and reads as a stutter. */
#intro.deal #stmode > #modeList { animation: none; }
#intro.deal #modeList > .mgroup {
animation: dealIn 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
#intro.deal #modeList > .mgroup:nth-child(1) { animation-delay: 0.09s; }
#intro.deal #modeList > .mgroup:nth-child(2) { animation-delay: 0.17s; }
#intro.deal #modeList > .mgroup:nth-child(3) { animation-delay: 0.25s; }
@media (prefers-reduced-motion: reduce) {
#intro.deal > .card > div:not(#splash) > *,
#intro.deal #modeList > .mgroup { animation: none; } }
/* 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; }
/* THREE REGISTERS NOW (2026-08-31). Bangers still ANNOUNCES — the primary button, PROMOTED.,
NEW PERSONAL BEST, and everything the canvas shouts (SET, CHAPTER, EPIC!). Silkscreen is
still the HUD: numbers, labels, anything you read at leisure. And the SCREEN TITLES moved to
Jersey 15: upright signage, which is what the strip at the top of a screen actually is — you
are not being shouted at, you are being told which room you are in. */
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
/* #intro h2 above carries an id, so this needs one too or the announcement face wins */
#intro .hdr h2 { font: 400 33px/1 'Jersey 15', Bangers, cursive; letter-spacing: 1px; }
/* A RUBRIC IS ONE WORD (2026-08-31, the text reduction), in the face that names rooms — and any
hint it carries is a chip on the right, not a second sentence. */
#intro h3 span { font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
#intro h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 20px 0 9px;
font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; color: var(--acid);
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; } /* (its face is set with the other titles above) */
/* UPRIGHT, not Bangers: the announcement face is drawn slanted, and a slanted arrow read as
italic on every screen. Silkscreen's ASCII < > are straight pixel arrows. */
.chev { width: 34px; height: 34px; flex: none; background: none; border: 2px solid var(--line);
color: var(--acid); font: 700 14px Silkscreen, monospace; 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); }
/* four numbers read better two-up than four-across on a phone — at four the labels wrap and
the row becomes a wall of two-line captions */
.figs.pair { display: grid; grid-template-columns: 1fr 1fr; }
.figs.pair .fig:nth-child(2n) { border-left: 2px solid var(--line); }
.figs.pair .fig:nth-child(n+3) { border-top: 2px solid var(--line); }
.fig { flex: 1; min-width: 0; padding: 11px 8px 12px; text-align: center; }
.fig + .fig { border-left: 2px solid var(--line); }
/* THE NUMBER NEVER MOVES (2026-08-31, Oliver: "manchmal sind die überschriften ein- manchmal
zweizeilig, das drückt die zahl runter"). The label gets two lines of room whether it needs
them or not, so every figure in a row sits on the same baseline. */
.fig b { display: flex; align-items: flex-start; justify-content: center; min-height: 22px;
font: 400 9px/1.25 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; }
/* THE FIXTURE BUTTON (THE SEASON): the shout stays the shout, and the fixture itself is the
quiet line under it — the same two-register rule the cards follow (Bangers announces, Silkscreen
informs). `b` is not bold here, it IS the announcement face. */
.btn.big b { display: block; font: inherit; }
.btn.big i { display: block; margin-top: 3px; font: 400 9px Silkscreen, monospace;
font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; }
.two { display: flex; gap: 8px; }
.two .btn { margin-bottom: 0; }
/* the secondary doors NAME (Jersey) and carry a NUMBER (Silkscreen, acid) — the text reduction
turned "RANKINGS / WORLD BOARD" into "BOARD / BEST 612": a label and a fact, no description */
.btn.sec { font: 400 18px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
#qNo { font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
.btn.sec i { display: block; margin-top: 3px; font: 700 10px Silkscreen, monospace;
font-style: normal; color: var(--acid); letter-spacing: 1px; }
/* 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, #who2 { display: flex; gap: 12px; align-items: flex-start; }
#whoCtl, #pCtl { 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, #pLook { 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, #pTours { display: flex; gap: 7px; }
#tours button, #pTours 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, #pTours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
/* THE IDENTITY SCREEN (the leagues, phase 1): name row = the input plus the die. */
#nameRow { display: flex; gap: 8px; }
#nameRow .tin { flex: 1; margin: 0; }
#pDice { width: 52px; flex: none; margin: 0; padding: 0; font-size: 20px; line-height: 1; }
/* a LOCKED court: the tile stays visible — it is a goal — but it answers no tap and sits back */
.court.lock { opacity: 0.45; filter: saturate(0.4); cursor: default; }
.court.lock i { color: var(--bad); }
/* THE PROMOTION CEREMONY: one centred column, the walk-on moment of the meta game. The court
tile is the reward made visible; the staggered drops are cheap CSS, no rAF (menu parks it). */
#promo .mid { gap: 10px; }
#prTag { margin: 0; font: 700 10px Silkscreen, monospace; color: var(--dim); letter-spacing: 3px;
animation: drop 0.5s ease-out; }
#prTitle { font: 400 44px Bangers, cursive; color: var(--acid); letter-spacing: 2px; margin: 0;
animation: drop 0.5s ease-out 0.15s backwards; }
#prLeague { margin: 0; font: 700 12px Silkscreen, monospace; color: var(--cream);
letter-spacing: 2px; animation: drop 0.5s ease-out 0.3s backwards; }
#prCourt { width: 74%; max-width: 300px; margin: 10px auto 0; align-self: center;
border: 2px solid var(--line);
image-rendering: pixelated; animation: drop 0.5s ease-out 0.5s backwards; }
#prCourtName { margin: 0; font: 400 10px Silkscreen, monospace; color: var(--acid);
letter-spacing: 1.5px; animation: drop 0.5s ease-out 0.62s backwards; }
/* THE CHARACTER CARD (the leagues, phase 3): the player writ large — sprite, crest, level,
honours, ledger. The honours row is a collection with visible empty slots: the unearned
badges sit dark, which is the whole incentive. */
#cdTop { display: flex; gap: 14px; align-items: flex-start; margin-top: 14px; }
#cdLook { position: static; display: block; flex: none; margin: 0; width: auto; height: 200px;
touch-action: auto; image-rendering: pixelated; }
#cdMeta { flex: 1; min-width: 0; display: grid; gap: 8px 11px; align-items: center;
grid-template-columns: auto 1fr;
grid-template-areas: "crest league" "crest level" "bar bar" "xp xp" "career career"; }
/* THE CREST STANDS BESIDE THE LEAGUE (2026-08-31, Oliver: on its own row it pushed the text
down), so the two things that say WHERE YOU ARE share a line instead of stacking. */
#cdBadge { grid-area: crest; width: 56px; image-rendering: pixelated; align-self: center; }
#cdLeague { grid-area: league; align-self: end; font: 700 10px/1.5 Silkscreen, monospace;
text-decoration: none; letter-spacing: 1.5px; }
#cdLeague i { display: block; font-style: normal; font-size: 9px; opacity: 0.85; }
#cdBar { grid-area: bar; }
#cdXp { grid-area: xp; }
#cdCareer { grid-area: career; }
#cdLevel { grid-area: level; align-self: start; font: 400 30px Bangers, cursive;
color: var(--cream); letter-spacing: 1.5px; }
#cdXp { display: block; margin-top: 5px; font: 400 9px Silkscreen, monospace; color: var(--dim);
text-decoration: none; letter-spacing: 1px; }
/* THE HONOURS ROW: a shield per league, and under an earned one the INSCRIPTION — the level it
was taken at (the cup board's tie-break, in the one place a once-in-a-career figure belongs). */
#cdBadges { display: flex; gap: 10px; align-items: flex-end; }
#cdBadges .badge { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column;
align-items: center; gap: 4px; }
/* ...and an earned shield SMOULDERS: two-pixel embers drifting off it, each on its own delay and
lane (main.embers). Pure CSS — the menu parks the game loop and this has no business waking it. */
/* they lift off the shield's TOP EDGE into the dark above it — at the old bottom:22px the whole
flight happened ACROSS the artwork, where a 3px gold speck is invisible (Oliver saw nothing) */
#cdBadges .badge em { position: absolute; bottom: 52px; width: 3px; height: 3px; background: #fff2c9;
box-shadow: 0 0 6px 2px rgba(232,178,58,0.75);
opacity: 0; pointer-events: none; animation-name: ember;
animation-timing-function: linear; animation-iteration-count: infinite; }
@keyframes ember {
0% { transform: translate(0, 6px) scale(0.6); opacity: 0; }
20% { opacity: 1; }
60% { transform: translate(3px, -10px) scale(1); opacity: 0.75; }
100% { transform: translate(-2px, -32px) scale(0.35); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { #cdBadges .badge em { display: none; } }
#cdBadges img { height: 46px; image-rendering: pixelated; }
#cdBadges .badge u { font: 700 7px Silkscreen, monospace; color: var(--acid); letter-spacing: 0.5px;
text-decoration: none; white-space: nowrap; }
#cdBadges .badge.dark img { filter: grayscale(1) brightness(0.3); }
#cdBadges .badge.dark u { color: #3f4d45; }
#cdBadges img.dark { filter: grayscale(1) brightness(0.3); }
/* earned badges BREATHE — a subtle gold halo, CSS-only (the menu parks the game loop, CSS
animations do not care). The dark slots stay dead, which is the point of them. */
#cdBadges .badge:not(.dark) img, #cdBadges img:not(.dark), #cdBadge {
animation: badgeGlow 2.6s ease-in-out infinite; }
@keyframes badgeGlow {
0%, 100% { filter: drop-shadow(0 0 2px rgba(232, 178, 58, 0.25)); }
50% { filter: drop-shadow(0 0 7px rgba(232, 178, 58, 0.75)); } }
.rkbadge { height: 15px; margin-right: 6px; image-rendering: pixelated; flex: none; }
/* THE LEAGUE PANEL: the setup's league-mode face. The badge breathes like the card's, the
name is the announcement face in the league's own colour, and the band's roster stands in a
row — felled bodies in colour with their all-time count, the rest grey and waiting. */
#lgPanel { display: none; }
#lgHead { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
#lgBadge { height: 56px; image-rendering: pixelated; animation: badgeGlow 2.6s ease-in-out infinite; }
#lgHead b { display: block; font: 400 24px Bangers, cursive; letter-spacing: 1.5px; }
#lgHead i { display: block; margin-top: 3px; font: 400 9px Silkscreen, monospace;
color: var(--dim); font-style: normal; letter-spacing: 1px; }
#lgFoes { display: flex; gap: 7px; }
.lgFoe { flex: 1; min-width: 0; padding: 7px 2px 6px; background: var(--panel);
border: 2px solid var(--line); text-align: center; }
.lgFoe img { height: 42px; image-rendering: pixelated; }
.lgFoe b { display: block; margin-top: 4px; font: 700 8px Silkscreen, monospace;
color: var(--cream); letter-spacing: 0.5px; white-space: nowrap; overflow: hidden; }
.lgFoe i { display: block; margin-top: 2px; font: 400 8px Silkscreen, monospace;
color: var(--acid); font-style: normal; }
.lgFoe.un { opacity: 0.55; }
.lgFoe.un img { filter: grayscale(1) brightness(0.6); }
.lgFoe.un i { color: var(--dim); }
/* the band's top rung is the final — it wears the door's own red */
.lgFoe.fin { border-color: var(--red); }
/* league modes: the venue is the league's own and not a choice — one row, no tap */
#courtList.fixed .court { cursor: default; }
/* the WAY-UP RIBBON, horizontal (Oliver's call after two diagonal cuts — a rotated pixel font
was the one anti-aliased thing in the menu): a red band running in from OVER the left border,
dissolving rightward through a real PIXEL FADE — an inline-SVG mask of 6px cells thresholded
against a Bayer matrix (four density steps), the dither the game's own art would use, never a
smooth gradient. The BAND lives on ::before and carries the mask; the HOST carries the text
and a drop-shadow FILTER — a filter on the parent shadows the already-masked child, so the
shadow survives the mask and follows the dither blocks (a box-shadow on the masked element
itself was eaten with it). */
.way { position: absolute; top: 12px; left: -8px; z-index: 2; white-space: nowrap;
padding: 8px 64px 8px 16px; font: 700 13px Silkscreen, monospace; font-style: normal;
color: #fff;
text-shadow: 1px 1px 0 rgba(0,0,0,0.55);
filter: drop-shadow(0 2px 3px rgba(0,0,0,0.5));
letter-spacing: 1px; text-decoration: none; }
.way::before { content: ''; position: absolute; inset: 0; z-index: -1; background: var(--red);
border-top: 1px solid rgba(255,255,255,0.35); border-bottom: 1px solid #69201c;
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%20200%2024%22%20shape-rendering=%22crispEdges%22%3E%3Cg%20fill=%22%23000%22%3E%3Crect%20x=%220%22%20y=%220%22%20width=%22140%22%20height=%2224%22/%3E%3Crect%20x=%22140%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3C/g%3E%3C/svg%3E");
-webkit-mask-size: 100% 100%; -webkit-mask-repeat: no-repeat;
mask-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%20200%2024%22%20shape-rendering=%22crispEdges%22%3E%3Cg%20fill=%22%23000%22%3E%3Crect%20x=%220%22%20y=%220%22%20width=%22140%22%20height=%2224%22/%3E%3Crect%20x=%22140%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3C/g%3E%3C/svg%3E");
mask-size: 100% 100%; mask-repeat: no-repeat; }
/* THE PLAYER PLAQUE: a LETTERHEAD, not a centred stack (Oliver: the stack sat cramped) — the
crest on the left, name over the big LEVEL · LEAGUE line beside it, the chevron on the
right; the bar runs the full width below and the goal line closes left-aligned. Translucent
surface, generous rows — the splash art, blood pool included, stays visible around it. */
/* ONE ROW (2026-08-31, the text reduction): crest, then name and level on one baseline, the bar
under them with the level it fills toward at its right end. The GOAL LINE is gone — the
fixture button right below says what to do, and saying it twice was the whole complaint. */
#plaque { position: relative; display: flex; align-items: center; gap: 13px; text-align: left;
margin: 24px auto 0; width: min(330px, 92%); box-sizing: border-box;
padding: 13px 36px 13px 15px; background: rgba(8, 14, 10, 0.48); border: 0;
border-radius: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
#plBadge { flex: none; height: 50px; image-rendering: pixelated;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
#plCol { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
#plTop { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
#plName { font: 400 21px 'Jersey 15', Bangers, cursive; color: var(--cream); letter-spacing: 0.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* the CALL belongs to the button below, not to the plaque — the level is a number now */
#plLv { flex: none; font: 700 10px Silkscreen, monospace; color: var(--acid); letter-spacing: 1px;
text-decoration: none; }
/* the bar wears the level it fills TOWARD at its right end — "▓▓░░ 13" says which way it runs
without a word of explanation (Oliver read a bare bar as total progress) */
#plRow { display: flex; align-items: center; gap: 7px; }
#plRow u, #cdBar u { font: 700 9px Silkscreen, monospace; color: var(--cream);
text-decoration: none; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); }
#plaque em { position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
font: 700 15px Silkscreen, monospace; font-style: normal; color: var(--acid); }
#plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10);
border: 1px solid var(--line); text-decoration: none; display: block; }
#cdBar s { height: 8px; }
#cdBar { display: flex; align-items: center; gap: 8px; }
#cdCareer { display: flex; align-items: center; gap: 8px; }
#cdCareer u { font: 700 8px Silkscreen, monospace; color: var(--dim); text-decoration: none;
letter-spacing: 1px; }
#plRow s i, #cdBar s i, #cdCareer s i { display: block; height: 100%; width: 0%;
background: linear-gradient(90deg, #3f9560, var(--acid) 55%, #e8b23a);
transition: width 0.4s ease; }
#cdBar s i, #cdCareer s i { transition: width 0.7s cubic-bezier(0.2, 0.7, 0.3, 1); }
/* 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, #trainList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
#bossList button, #trainList 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, #trainList b { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
text-align: center; line-height: 1.35; }
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
letter-spacing: 1px; }
/* THE REVANCHE ROW (THE SEASON, widened in the text reduction): the one that put you out of the
cup, across the top of the grid — one body out of eighteen with an actual reason, so it gets
the width to say so in three words. */
#trainList button.put { grid-column: 1 / -1; flex-direction: row; align-items: center; gap: 12px;
padding: 10px 12px; text-align: left; position: relative;
border-color: var(--red); background: rgba(164,35,31,0.16); }
#trainList button.put img { height: 52px; }
#trainList button.put > span { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px;
text-align: left; }
#trainList button.put b { font: 400 24px/1.05 'Jersey 15', Silkscreen, monospace; text-align: left; }
#trainList button.put i { color: #e08a7c; letter-spacing: 1px; }
#trainList button.put em { position: absolute; right: 12px; font: 700 15px Silkscreen, monospace;
font-style: normal; color: var(--bad); }
/* the rung's NAME (Jersey) over its number — "No. 7" was three characters of nothing */
#trainList b { font: 400 17px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
/* ...and the list ends on the league that opens the rest of the ladder, drawn as the goal it is */
#trainList .goal { grid-column: 1 / -1; padding: 14px 6px; border: 2px dashed var(--line);
text-align: center; }
#trainList .goal b { display: block; color: #4a5a52; }
#trainList .goal i { font: 400 9px Silkscreen, monospace; font-style: normal; color: #4a5a52;
letter-spacing: 1px; }
/* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the
draw and the walk-on wear), which reads at a third of the boss portrait's height */
#trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; }
/* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */
.btn[disabled] { opacity: 0.32; cursor: default; }
/* the fixture line under the new court: the same two registers the plaque uses — the name in
the league's own acid, what it costs you in the quiet face */
#prNext { margin: 12px 0 0; font: 400 10px Silkscreen, monospace; color: var(--acid);
letter-spacing: 1px; line-height: 1.6; }
#prNext u { display: block; margin-top: 3px; font-size: 9px; color: var(--dim);
text-decoration: none; letter-spacing: 0.5px; }
/* 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; }
/* THE CAPTION'S VEIL (2026-08-31). The blur went in and came straight back out — on
nearest-neighbour art it read as a smear, not as depth (Oliver: "sieht komisch aus"). What is
left is what he asked for instead: a gradient RISING FROM THE BOTTOM EDGE, solid where the
words are and gone by the time it reaches the art. Same job, no filter, no edge. */
#modeList button.mode::after, #modeSub button.mode::after {
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 62%;
background: linear-gradient(0deg, rgba(6,10,8,0.94) 0%, rgba(6,10,8,0.86) 22%,
rgba(6,10,8,0.5) 58%, rgba(6,10,8,0) 100%);
pointer-events: none; }
/* the words and the chips ride ABOVE the veil — a pseudo-element is the last thing painted */
#modeList .cb, #modeSub .cb { position: relative; z-index: 2; }
#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 SECTIONS (THE SEASON): the wall is three rooms — one path up, a room to practise in, a
room where nothing counts — so each is a real section with its posters INSIDE it, not a label
floating over a stack. The heading is deliberately NOT the announcement face (Oliver: it read
as a second card headline and the two competed); Silkscreen, big and letter-spaced, keeps it
a label of a room. No rule, no left band either — the air above each heading does the
grouping, which is what air is for. */
#modeList .mgroup { margin: 0 0 10px; }
#modeList .mgroup + .mgroup { margin-top: 17px; }
/* HOW A ROOM DEALS ITS POSTERS (the text reduction). The wall used to be six identical 148px
slabs and ran over two screens; the fixture is a HERO now, the training pair stands side by
side, and the casual three stack shorter. All of it fits one screen. */
#modeList .mgroup .shelf { display: flex; flex-direction: column; }
#modeList .mgroup.pair .shelf { flex-direction: row; gap: 8px; }
#modeList .mgroup.pair button.mode { flex: 1; min-width: 0; min-height: 106px; margin-bottom: 0; }
#modeList .mgroup.stack button.mode { min-height: 96px; }
#modeList .mgroup.hero button.mode { min-height: 142px; }
/* the poster names: Jersey NAMES, and the hero is the one poster that CALLS (Bangers) */
#modeList .cb b { font: 400 30px/1 'Jersey 15', Bangers, cursive; letter-spacing: 0.5px; }
#modeList .mgroup.pair .cb b { font-size: 27px; }
#modeList .mgroup.hero .cb b { font: 400 32px/0.95 Bangers, cursive; letter-spacing: 1px; }
/* ...and the hero's second line is the fixture, the only strap left on the wall */
#modeList .mgroup.hero .cb i { color: #f0cfc4; font-size: 10px; margin-top: 3px; }
/* text-align rides down from the card, which centres everything on these screens — the section
header is a header, and headers on this screen are left-aligned (the .hdr title, the posters'
own captions) */
#modeList .mgroup > header { margin: 0 0 11px; text-align: left; }
/* THE SHELF LABELS SHOUT, quietly (2026-08-31, Oliver tried three: Bangers first, then the HUD
face, then the platform's UI face, and came back to the announcement face). It works here now
because the header lost its rule and its band and gained air: the size does the hierarchy
(20 against the screen title's 26) instead of the typeface having to. */
/* SCOPED TO THE HEADER, not to the section: the posters live INSIDE .mgroup now, so a bare
descendant selector here restyled every caption on the wall (it stayed invisible while this
rule still named Silkscreen — the moment the family changed, the posters changed with it). */
#modeList .mgroup > header b { display: block; letter-spacing: 0.5px; color: var(--acid);
text-shadow: none; font: 400 17px/1.2 'Jersey 15', Bangers, cursive;
letter-spacing: 2px; }
/* ...and the strap goes back to the quiet register under it: a shouted line over a system-sans
line was two foreign voices in one header */
#modeList .mgroup > header i { display: block; margin: 4px 0 0; letter-spacing: 0.5px;
color: var(--dim); font-style: normal; text-shadow: none;
font: 400 9px/1.4 Silkscreen, monospace; }
#modeList .mgroup button.mode:last-child { margin-bottom: 0; }
/* 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%; display: flex; align-items: center; justify-content: space-between;
gap: 10px; padding: 26px 16px; background: var(--panel); border: 2px solid var(--line);
color: var(--cream); cursor: pointer; -webkit-tap-highlight-color: transparent;
position: relative; text-align: left; }
#modeSub button.chip b { font: 400 30px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
/* how hard it is, counted: as many pips lit as the chip is steep, and the last one wears the
colour the game uses for a threat */
.pips { display: flex; gap: 5px; }
.pips i { width: 10px; height: 10px; background: rgba(255,255,255,0.12); }
.pips i.on { background: var(--acid); }
#modeSub button.chip.hot .pips i.on { background: var(--bad); }
#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: 84px; height: 60px; 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: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
/* the court NAMES itself (Jersey) and its two meters say the rest — the description line is gone */
#courtList b { font: 400 22px/1.1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
#courtList i { margin: 3px 0 5px; font: 700 9px Silkscreen, monospace; color: var(--bad);
letter-spacing: 1px; }
#courtList i[hidden] { display: none; }
#courtList i, #modeList i, #modeSub i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; 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; }
/* ...except the splash's own secondary door: OTHER GAMES sat flush on the STATS/RANKINGS row */
.foot.bare #mOther { margin-bottom: 12px; }
/* 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 DEAL-IN (2026-08-31, Oliver: "es wäre toll, wenn sich die buttons nach dem laden aufbauen
würden — arcade style"). The canvas modals have always arrived on an easeOutBack overshoot
rather than decelerating politely into place; this is that same snap, in CSS, for the one
moment the menu has an entrance to make. It plays on EVERY arrival at the title screen, not
just the boot (Oliver) — main.dealSplash re-arms the class, because an animation whose class
never left cannot restart itself. */
@keyframes dealIn {
from { opacity: 0; transform: translateX(-56px); }
60% { opacity: 1; }
to { opacity: 1; transform: translateX(0); }
}
#intro.deal #splash #plaque,
#intro.deal #splash .foot.bare > * {
animation: dealIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
#intro.deal #splash #plaque { animation-delay: 0.02s; }
#intro.deal #splash #mStart { animation-delay: 0.12s; }
#intro.deal #splash #mOther { animation-delay: 0.19s; }
/* the pair of secondary buttons is a CLASS, so it needs the same weight as the shorthand above
to override its delay — the id selectors around it get there on their own */
#intro.deal #splash .foot.bare > .two { animation-delay: 0.26s; }
#intro.deal #splash #build { animation-delay: 0.33s; }
@media (prefers-reduced-motion: reduce) {
#intro.deal #splash #plaque, #intro.deal #splash .foot.bare > * { animation: none; } }
/* 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; }
/* NOTHING DATA-DRIVEN DURING THE BOOT (2026-08-31, Oliver: "die oberfläche ist hier regelmäßig
in einem undefinierten zustand"). The plaque used to stand there as a skeleton, which is still
a plaque saying LEVEL 1 to a level-25 player for as long as the profile takes to land. The
title and the court stay — they are the background and they are never undefined. */
#splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none
let the title slide down the screen and jump back up the moment the profile landed */
#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; }
/* a GRID, not a wrapping flex row: `flex: 1 1 62px` gave the last row's tiles the whole width
to share, so a shelf of nine came out in three different sizes (Oliver) */
#stW { display: grid; grid-template-columns: repeat(auto-fill, minmax(66px, 1fr)); gap: 6px; }
.wtile { 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. -->
<!-- BUSY FROM THE FIRST PAINT (2026-08-31, Oliver: "zeigt sich während des ladens noch immer
die box mit LEVEL 1 und springt dann erst auf die tatsächlichen werte"). This module is
deferred, so the browser paints this markup BEFORE main.js can add the class — the
placeholder values in the tags below were on screen for the whole of that gap. The class
ships in the HTML now; main.js only ever takes it OFF (see the boot gate there). -->
<div id="splash" class="busy">
<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>
<!-- THE PLAYER PLAQUE (the leagues): centred under the title on a translucent surface,
so the foot's blood pool stays visible. One tap opens the card (refreshSplash fills). -->
<button id="plaque">
<img id="plBadge" alt="">
<span id="plCol">
<span id="plTop"><b id="plName"></b><u id="plLv">LEVEL 1</u></span>
<span id="plRow"><s><i id="plFill"></i></s><u id="plTo">2</u></span>
</span>
<em>&gt;</em>
</button>
</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>
<!-- THE FRONT DOOR IS THE FIXTURE (2026-08-31, THE SEASON). The primary button used to say
PLAY MATCH and open a wall of posters, which asked the player a question the game
already knows the answer to. It names the next cup fixture now and enters the draw;
everything else lives one tap down, behind OTHER GAMES. -->
<button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
<button class="btn sec" id="mOther">OTHER GAMES</button>
<div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0</i></button>
<button class="btn sec" id="mRanks">BOARD<i id="mBest">BEST 0</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">&lt;</button><h2>PICK A GAME</h2></div>
<div id="modeList"></div>
</div>
<div id="stsub">
<div class="hdr"><button class="chev" id="subBack">&lt;</button><h2 id="subTitle">DIFFICULTY</h2></div>
<div id="modeSub"></div>
</div>
<div id="setup">
<div class="hdr"><button class="chev" id="sBack">&lt;</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 id="s01">01 · PLAYER</h3>
<div id="who">
<canvas id="look" width="128" 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>
<!-- THE LEAGUE PANEL (the leagues): on the league modes this stands where the player block
does elsewhere — the identity already lives on the card, so the setup shows the thing
that matters HERE instead: your league, writ large, and who of its band you have
already felled (all-time, from the stats' own per-foe tallies). Built by
main.buildLeaguePanel, toggled by syncSetup. -->
<div id="lgPanel">
<div id="lgHead">
<img id="lgBadge" alt="">
<div><b id="lgName"></b><i id="lgSub"></i></div>
</div>
<div id="lgFoes"></div>
</div>
<h3 id="s02">02 · COURT</h3>
<div id="courtList"></div>
<div class="foot">
<button class="btn big" id="cStart">SERVE UP</button>
</div>
</div>
<!-- THE IDENTITY (the leagues, phase 1): the first boot asks who walks on — a name (prefilled
with a zombie name, the die rolls another), the tour and the colours. Everything here
writes the SAME stores the setup screen reads (stats' name key, the picker localStorage
keys), so the two screens cannot disagree about who you are. Rebuilt on every visit by
main.buildPlayer, so the .on states track the setup screen's. -->
<div id="player">
<div class="hdr"><button class="chev" id="pBack">&lt;</button><h2>YOUR PLAYER</h2></div>
<h3>NAME<span>YOURS ON EVERY BOARD</span></h3>
<div id="nameRow">
<input id="pName" class="tin" maxlength="12" autocomplete="off" spellcheck="false">
<button class="btn" id="pDice" aria-label="roll a zombie name">&#127922;</button>
</div>
<h3>THE BODY</h3>
<div id="who2">
<canvas id="pLook" width="128" height="224"></canvas>
<div id="pCtl">
<div id="pTours"></div>
<u class="swlab">KIT<i id="pKitName"></i></u>
<div id="pKitRow" class="swrow"></div>
<u class="swlab">SKIN<i id="pSkinName"></i></u>
<div id="pSkinRow" class="swrow"></div>
</div>
</div>
<div class="foot">
<p class="cent">THE CLUB WILL LEARN YOUR NAME.</p>
<button class="btn big" id="pGo">ENTER THE CLUB</button>
</div>
</div>
<!-- THE PROMOTION (the leagues): the ceremony between the cup summary and the title screen.
Built by main.buildPromo from the profile's fresh league; the court tile IS the reward. -->
<div id="promo">
<div class="mid cent">
<p id="prTag">THE CUP IS YOURS</p>
<h2 id="prTitle">PROMOTED.</h2>
<p id="prLeague"></p>
<img id="prCourt" alt="">
<p id="prCourtName"></p>
<!-- THE SEASON'S SECOND BEAT (2026-08-31): the promotion is also the season opener, so it
names the fixture waiting in the new league rather than ending on a court tile. No
extra screen — a tap between the player and the court is what we were avoiding. -->
<p id="prNext"></p>
</div>
<div class="foot bare">
<button class="btn big" id="prGo">WALK ON</button>
</div>
</div>
<!-- THE CHARACTER CARD (the leagues): tap the splash's level strip to open. Built by
main.buildCard from the profile + stats; the honours row shows every badge slot, earned
bright and unearned dark. -->
<div id="card">
<div class="hdr"><button class="chev" id="cdBack">&lt;</button><h2 id="cdName">YOUR PLAYER</h2></div>
<div id="cdTop">
<canvas id="cdLook" width="128" height="224"></canvas>
<div id="cdMeta">
<img id="cdBadge" alt="">
<u id="cdLeague"></u>
<b id="cdLevel">LEVEL 1</b>
<div id="cdBar"><u id="cdFrom">1</u><s><i id="cdFill"></i></s><u id="cdTo">2</u></div>
<u id="cdXp"></u>
<div id="cdCareer"><u>CAREER</u><s><i id="cdCareerFill"></i></s></div>
</div>
</div>
<h3>HONOURS<span>WIN THE CUP TO CLIMB</span></h3>
<div id="cdBadges"></div>
<h3>THE LEDGER<span>ALL TIME</span></h3>
<div class="figs" id="cdFigs1"></div>
<div style="height:8px"></div>
<div class="figs" id="cdFigs2"></div>
<div class="foot bare">
<button class="btn sec" id="cdEdit">EDIT PLAYER<i>NAME · TOUR · COLOURS</i></button>
</div>
</div>
<!-- THE TRAINING COURT's opponent picker: the whole ladder face-up, tap a body to drill it.
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
<div id="train">
<div class="hdr"><button class="chev" id="trBack">&lt;</button><h2>TRAINING</h2></div>
<div id="trainList"></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">&lt;</button><h2>VS A FRIEND</h2></div>
<div id="vsPick">
<h3>YOU</h3>
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<h3>HOST</h3>
<button class="btn" id="vsHostC">CLASSIC</button>
<button class="btn" id="vsHostD">DEATH MATCH</button>
<h3>JOIN</h3>
<div class="joinrow">
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<button class="btn" id="vsJoin">GO</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">&lt;</button><h2>BODY COUNT</h2></div>
<!-- NO TABS (2026-08-31). This screen carried three MODE tabs times three PERIOD tabs, and
the same three boxes meant something different in every one of the nine states. It is
your career now, in four numbers that always mean the same thing, plus the two things
the screen is actually good at: the bodies you have collected, and the rack you fired. -->
<div class="figs pair" id="stFigs"></div>
<h3>BY OPPONENT<span>ALL TIME</span></h3>
<div id="stZ"></div>
<h3>BY WEAPON</h3>
<div id="stW"></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">&lt;</button><h2>WORLD BOARD</h2></div>
<!-- TWO boards, not three (2026-08-31): classic tennis is out of competition by design now
— no league, no record, no stakes — so ranking it contradicted the room it lives in. -->
<div class="tabs" id="rkModes">
<button class="on" data-m="u">THE CUP</button>
<button data-m="g">SPLATTER</button>
<button data-m="z">THE CLIMB</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">&lt;</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">&lt;</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=343"></script>
</body>
</html>