Track a match: length, score sheet, and a clock that survives a quit

Match length is a new first row in the settings, 1 to 29 points, stepping by
2 so it can only ever be odd. The Time setting now means time *per point* and
each clock starts at time × points, which the settings sheet spells out.

The middle bar icon opens a score sheet. Edits are staged there and only
reach the state on Done, which then does one of three things:

  nothing changed  turn, delay position and moves all untouched
  score changed    displayed times become the reserves, then back to the
                   first tap with moves zeroed — the clocks keep their value
  clock changed    the spent delay is added back on so the panel shows
                   exactly what was typed, and the turn carries on

Tapping outside the sheet discards the staged edits, so a stray tap can't
restart a game.

reset() was called both by the Reset button and by every settings change, so
clearing the score in it would have wiped a live match whenever the delay was
nudged. It splits into resetClocks() and resetAll(); only the button clears
the score. Shortening a match clamps a score that no longer fits.

The whole game now persists, not just the settings: score, both reserves,
moves, whose turn and how much delay was left, saved on the discrete
transitions and on pagehide/visibilitychange. Time passing while the app is
dead can't be charged to anyone, so a running clock is stored as paused and
comes back on the play button. A malformed saved game is ignored rather than
half-applied.

Each panel shows its own score first, stacked above its move count at the
top left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 14:39:32 +02:00
co-authored by Claude Opus 5
parent 64bc4a9a34
commit 92bb31b18e
3 changed files with 362 additions and 40 deletions
+317 -29
View File
@@ -75,16 +75,23 @@
.panel--top .inner{padding:14px 20px max(14px,env(safe-area-inset-top))}
.panel--bottom .inner{padding:14px 20px max(14px,env(safe-area-inset-bottom))}
.moves{
align-self:flex-end;
.head{
display:flex;flex-direction:column;align-items:flex-start;
gap:3px;
order:-1;
}
.score,.moves{
font-size:clamp(13px,3.7vw,20px);
font-weight:700;
letter-spacing:.01em;
color:var(--idle-mute);
order:-1;
white-space:nowrap;
}
.panel[data-state="active"] .score,
.panel[data-state="active"] .moves,
.panel[data-state="flagged"] .score,
.panel[data-state="flagged"] .moves{color:var(--accent-mute)}
.panel[data-state="flagged"] .score,
.panel[data-state="flagged"] .moves{color:rgba(255,255,255,.55)}
.stack{
@@ -138,7 +145,7 @@
display:flex;align-items:center;
padding:0 clamp(12px,4vw,28px);
}
.bar .gap{flex:1.5}
.bar .gap{flex:.75}
.btn{
flex:1;
height:100%;
@@ -303,7 +310,10 @@
<div class="app">
<section class="panel panel--top" data-side="0" data-state="idle">
<div class="inner">
<div class="moves">Moves: <span data-moves>0</span></div>
<div class="head">
<div class="score">Score: <span data-score>0 - 0</span>, out of <span data-target>1</span></div>
<div class="moves">Moves: <span data-moves>0</span></div>
</div>
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="delay" data-delay>
@@ -330,6 +340,15 @@
</svg>
</button>
<span class="gap"></span>
<button class="btn" id="score" aria-label="Score">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="3" width="16" height="18" rx="2.6"/>
<path d="M12 7.5v10"/>
<path d="M7.6 10.6h2.6"/><path d="M13.8 10.6h2.6"/>
<path d="M7.6 14.6h2.6"/><path d="M13.8 14.6h2.6"/>
</svg>
</button>
<span class="gap"></span>
<button class="btn" id="settings" aria-label="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.3"/>
@@ -351,7 +370,10 @@
<section class="panel panel--bottom" data-side="1" data-state="idle">
<div class="inner">
<div class="moves">Moves: <span data-moves>0</span></div>
<div class="head">
<div class="score">Score: <span data-score>0 - 0</span>, out of <span data-target>1</span></div>
<div class="moves">Moves: <span data-moves>0</span></div>
</div>
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="delay" data-delay>
@@ -368,6 +390,18 @@
<div class="sheet" id="sheet" role="dialog" aria-modal="true" aria-label="Settings">
<div class="grab"></div>
<div class="row">
<div>
<h2>Match</h2>
<p>Points to play to.</p>
</div>
<div class="stepper">
<button class="step" data-points-adj="-2"></button>
<span class="val"><span id="v-points">1</span><span class="unit">pts</span></span>
<button class="step" data-points-adj="2">+</button>
</div>
</div>
<div class="row row--presets">
<button class="preset" data-base="120000" data-preset-delay="12000" aria-pressed="false">2m / 12s</button>
<button class="preset" data-base="180000" data-preset-delay="12000" aria-pressed="false">3m / 12s</button>
@@ -377,7 +411,7 @@
<div class="row row--wide">
<div>
<h2>Time</h2>
<p>Reserve time per player.</p>
<p id="p-time">Reserve time per point.</p>
</div>
<div class="stepper">
<button class="step" data-time-adj="-60"></button>
@@ -418,7 +452,67 @@
</div>
<button class="done" id="done">Done</button>
<p class="hint">Changing time or delay resets the clock.</p>
<p class="hint">Changing match, time or delay resets the clocks. The score is left alone.</p>
</div>
<div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score">
<div class="grab"></div>
<div class="row">
<div>
<h2>You</h2>
<p>Points won, this end of the phone.</p>
</div>
<div class="stepper">
<button class="step" data-score-adj="-1" data-who="1"></button>
<span class="val"><span id="v-you">0</span><span class="unit">pts</span></span>
<button class="step" data-score-adj="1" data-who="1">+</button>
</div>
</div>
<div class="row">
<div>
<h2>Them</h2>
<p>Points won, far end.</p>
</div>
<div class="stepper">
<button class="step" data-score-adj="-1" data-who="0"></button>
<span class="val"><span id="v-them">0</span><span class="unit">pts</span></span>
<button class="step" data-score-adj="1" data-who="0">+</button>
</div>
</div>
<div class="row row--wide">
<div>
<h2>Your clock</h2>
</div>
<div class="stepper">
<button class="step" data-clock-adj="-60" data-who="1"></button>
<span class="val"><span id="v-you-min">3</span><span class="unit">min</span></span>
<button class="step" data-clock-adj="60" data-who="1">+</button>
<button class="step" data-clock-adj="-1" data-who="1" style="margin-left:8px"></button>
<span class="val"><span id="v-you-sec">00</span><span class="unit">sec</span></span>
<button class="step" data-clock-adj="1" data-who="1">+</button>
</div>
</div>
<div class="row row--wide">
<div>
<h2>Their clock</h2>
</div>
<div class="stepper">
<button class="step" data-clock-adj="-60" data-who="0"></button>
<span class="val"><span id="v-them-min">3</span><span class="unit">min</span></span>
<button class="step" data-clock-adj="60" data-who="0">+</button>
<button class="step" data-clock-adj="-1" data-who="0" style="margin-left:8px"></button>
<span class="val"><span id="v-them-sec">00</span><span class="unit">sec</span></span>
<button class="step" data-clock-adj="1" data-who="0">+</button>
</div>
</div>
<button class="done" id="score-done">Done</button>
<p class="hint">Changing the score starts a new game: moves back to zero, turn back to
the first tap. The clocks keep their times.</p>
</div>
<script>
@@ -444,18 +538,24 @@
];
var cfg = {
base: store.num("bg.base", 180000),
delay: store.num("bg.delay", 15000),
theme: store.get("bg.theme", "sage"),
sound: store.get("bg.sound", "1") === "1"
base: store.num("bg.base", 180000), // reserve per point
delay: store.num("bg.delay", 15000),
points: store.num("bg.points", 1), // match length, odd, 1..29
theme: store.get("bg.theme", "sage"),
sound: store.get("bg.sound", "1") === "1"
};
if(!(cfg.points >= 1 && cfg.points <= 29 && cfg.points % 2 === 1)) cfg.points = 1;
// a match to N points is worth N times the per-point reserve
function matchTime(){ return cfg.base * cfg.points; }
/* ============ state ============ */
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
var st = {
phase: IDLE,
active: -1,
reserve: [cfg.base, cfg.base], // reserve banked at the start of the current turn
reserve: [matchTime(), matchTime()], // reserve banked at the start of the current turn
score: [0, 0], // match points, by side
moves: [0, 0],
turnAt: 0, // performance.now() when the turn began
held: 0, // frozen elapsed while paused
@@ -469,6 +569,8 @@
root: p,
time: p.querySelector("[data-time]"),
moves: p.querySelector("[data-moves]"),
score: p.querySelector("[data-score]"),
target: p.querySelector("[data-target]"),
delay: p.querySelector("[data-delay]"),
fill: p.querySelector("[data-fill]"),
dnum: p.querySelector("[data-dnum]")
@@ -478,6 +580,7 @@
var btnSound = document.getElementById("sound");
var btnReset = document.getElementById("reset");
var sheet = document.getElementById("sheet");
var scoreSheet = document.getElementById("scoresheet");
var scrim = document.getElementById("scrim");
/* ============ audio (synthesised — no files, works offline) ============ */
@@ -551,11 +654,24 @@
return m + ":" + (s < 10 ? "0" : "") + s;
}
// is this side's clock the one the turn is on?
function isLive(i){
return i === st.active && (st.phase === RUN || st.phase === PAUSE);
}
// how much of this turn has already come off the reserve (0 while the delay runs)
function consumed(i){
return isLive(i) ? Math.max(0, elapsed() - cfg.delay) : 0;
}
// the number the panel is showing, which is what the score sheet edits
function shownTime(i){
return Math.max(0, st.reserve[i] - consumed(i));
}
/* ============ render ============ */
function render(){
var e = elapsed();
for(var i = 0; i < 2; i++){
var live = (i === st.active && (st.phase === RUN || st.phase === PAUSE));
var live = isLive(i);
var main = st.reserve[i], dLeft = 0;
if(live){
@@ -571,6 +687,8 @@
el[i].time.textContent = fmt(main);
el[i].moves.textContent = st.moves[i];
el[i].score.textContent = st.score[i] + " - " + st.score[1 - i];
el[i].target.textContent = cfg.points;
var showDelay = live && cfg.delay > 0 && dLeft > 0;
el[i].delay.classList.toggle("on", showDelay);
@@ -594,6 +712,7 @@
render();
sndFlag();
buzz([90, 70, 90]);
saveGame();
}
function tap(side){
@@ -607,6 +726,7 @@
st.phase = RUN;
paintPlay(); // the bar has to show what the clock is doing
sndSwap(); buzz(14);
saveGame();
return;
}
if(side !== st.active) return; // the waiting player can't stop the clock
@@ -618,6 +738,7 @@
st.turnAt = performance.now();
st.ticked = false;
sndSwap(); buzz(14);
saveGame();
}
function pauseNow(){
@@ -625,6 +746,7 @@
st.held = elapsed();
st.phase = PAUSE;
paintPlay();
saveGame();
}
function togglePause(){
@@ -636,6 +758,7 @@
st.phase = RUN;
}else return;
paintPlay();
saveGame();
}
/* SVG elements aren't HTMLElements, so they have no .hidden property —
@@ -652,16 +775,79 @@
btnPlay.setAttribute("aria-label", showPlay ? "Start clock" : "Pause clock");
}
function reset(){
disarm();
// back to the first tap of a game, clocks untouched
function newGame(){
st.phase = IDLE; st.active = -1;
st.reserve = [cfg.base, cfg.base];
st.moves = [0, 0];
st.held = 0; st.ticked = false;
paintPlay();
render();
}
// clocks back to full for the match length; the score is not this function's business
function resetClocks(){
disarm();
st.reserve = [matchTime(), matchTime()];
newGame();
saveGame();
}
// the Reset button — a whole new match
function resetAll(){
st.score = [0, 0];
resetClocks();
}
function clampScore(){
st.score = [Math.min(cfg.points, st.score[0]), Math.min(cfg.points, st.score[1])];
}
/* ============ the game survives a force-quit ============ */
// Time that passes while the app is dead can't be charged to anyone, so a
// running clock is stored as paused and comes back on the play button.
function saveGame(){
var mid = (st.phase === RUN || st.phase === PAUSE);
try{
store.set("bg.game", JSON.stringify({
v: 1,
score: st.score, reserve: st.reserve, moves: st.moves,
active: st.active,
held: mid ? elapsed() : 0,
phase: st.phase === RUN ? PAUSE : st.phase
}));
}catch(e){}
}
function loadGame(){
var raw = store.get("bg.game", null), g;
if(!raw) return;
try{ g = JSON.parse(raw); }catch(e){ return; }
// anything malformed is ignored rather than half-applied
function pair(a){
return a && a.length === 2 && isFinite(a[0]) && isFinite(a[1]) && a[0] >= 0 && a[1] >= 0;
}
if(!g || g.v !== 1 || !pair(g.score) || !pair(g.reserve) || !pair(g.moves)) return;
if(!isFinite(g.held) || g.held < 0) return;
if([IDLE, RUN, PAUSE, FLAG].indexOf(g.phase) < 0) return;
if([-1, 0, 1].indexOf(g.active) < 0) return;
st.score = [g.score[0], g.score[1]];
clampScore(); // the match may have been shortened since
st.reserve = [g.reserve[0], g.reserve[1]];
st.moves = [g.moves[0], g.moves[1]];
st.active = g.active;
st.phase = g.phase === RUN ? PAUSE : g.phase;
st.held = st.phase === PAUSE ? g.held : 0;
st.ticked = st.held > cfg.delay; // don't re-tick a delay that already expired
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
}
// the only hooks iOS reliably fires before killing an app
document.addEventListener("visibilitychange", function(){
if(document.visibilityState === "hidden") saveGame();
});
window.addEventListener("pagehide", saveGame);
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
var armT = null;
function disarm(){
@@ -670,7 +856,7 @@
btnReset.setAttribute("aria-label", "Reset clock");
}
function armReset(){
if(armT){ disarm(); reset(); buzz(20); return; } // second tap inside the window
if(armT){ disarm(); resetAll(); buzz(20); return; } // second tap inside the window
btnReset.classList.remove("armed");
void btnReset.offsetWidth; // restart the drain animation
btnReset.classList.add("armed");
@@ -704,11 +890,21 @@
swWrap.appendChild(b);
});
function clock(ms){ // m:ss, for copy rather than the big readout
var s = Math.round(ms / 1000);
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}
function paintSettings(){
var s = Math.round(cfg.base / 1000);
document.getElementById("v-min").textContent = Math.floor(s / 60);
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000);
document.getElementById("v-points").textContent = cfg.points;
document.getElementById("p-time").textContent = cfg.points === 1
? "Reserve time per point."
: "Reserve time per point. " + cfg.points + " × " + clock(cfg.base) +
" = " + clock(matchTime()) + " each.";
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
showIcon(btnSound, "on", cfg.sound);
@@ -731,7 +927,18 @@
cfg.delay = parseInt(b.dataset.presetDelay, 10);
store.set("bg.base", String(cfg.base));
store.set("bg.delay", String(cfg.delay));
paintSettings(); reset();
paintSettings(); resetClocks();
});
});
[].forEach.call(document.querySelectorAll("[data-points-adj]"), function(b){
b.addEventListener("click", function(){
// stepping by 2 from 1 keeps it odd without any parity juggling
var next = cfg.points + parseInt(b.dataset.pointsAdj, 10);
cfg.points = Math.min(29, Math.max(1, next));
store.set("bg.points", String(cfg.points));
clampScore(); // a shorter match can't hold a bigger score
paintSettings(); resetClocks();
});
});
@@ -740,7 +947,7 @@
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
store.set("bg.base", String(cfg.base));
paintSettings(); reset();
paintSettings(); resetClocks();
});
});
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
@@ -748,7 +955,7 @@
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
cfg.delay = Math.min(60000, Math.max(0, next));
store.set("bg.delay", String(cfg.delay));
paintSettings(); reset();
paintSettings(); resetClocks();
});
});
@@ -761,18 +968,98 @@
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
function openSheet(on){
sheet.classList.toggle("on", on);
scrim.classList.toggle("on", on);
/* ============ sheets ============ */
var openNode = null;
function openSheet(node){
openNode = node;
sheet.classList.toggle("on", node === sheet);
scoreSheet.classList.toggle("on", node === scoreSheet);
scrim.classList.toggle("on", !!node);
}
function closeSheet(){ openSheet(null); }
document.getElementById("settings").addEventListener("click", function(){
pauseNow(); // nobody's clock runs while the sheet is open
pauseNow(); // nobody's clock runs while a sheet is open
disarm();
paintSettings();
openSheet(true);
openSheet(sheet);
});
document.getElementById("done").addEventListener("click", closeSheet);
// tapping outside the score sheet discards the staged edits rather than
// committing them — a stray tap must never restart a game
scrim.addEventListener("click", closeSheet);
/* ============ score sheet ============ */
// Edits are staged here and only reach `st` on Done, so opening the sheet,
// fiddling and tapping away changes nothing.
var edit = null, before = null;
function paintScore(){
document.getElementById("v-you").textContent = edit.score[1];
document.getElementById("v-them").textContent = edit.score[0];
[[1, "you"], [0, "them"]].forEach(function(p){
var s = Math.round(edit.time[p[0]] / 1000);
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2);
});
}
function openScore(){
pauseNow();
disarm();
// seed from what the panels are showing, not from the banked reserve — for
// the interrupted turn those differ by the delay already spent
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
before = { score: edit.score.slice(), time: edit.time.slice() };
paintScore();
openSheet(scoreSheet);
}
function applyScore(){
if(!edit) return;
var scoreChanged = edit.score[0] !== before.score[0] || edit.score[1] !== before.score[1];
var timeChanged = edit.time[0] !== before.time[0] || edit.time[1] !== before.time[1];
if(!scoreChanged && !timeChanged) return; // Done with no edits changes nothing
if(scoreChanged){
// the displayed times become the reserves, which banks the interrupted
// turn's spend — "the clocks keep their value"
st.score = edit.score.slice();
st.reserve = [edit.time[0], edit.time[1]];
newGame();
}else{
// a correction mid-game: put the spent delay back on so the panel shows
// exactly what was typed, and leave the turn and its delay alone
st.reserve = [edit.time[0] + consumed(0), edit.time[1] + consumed(1)];
render(); // every other mutator repaints; don't wait a frame
}
saveGame();
}
[].forEach.call(document.querySelectorAll("[data-score-adj]"), function(b){
b.addEventListener("click", function(){
if(!edit) return;
var who = parseInt(b.dataset.who, 10);
var next = edit.score[who] + parseInt(b.dataset.scoreAdj, 10);
edit.score[who] = Math.min(cfg.points, Math.max(0, next));
paintScore();
});
});
[].forEach.call(document.querySelectorAll("[data-clock-adj]"), function(b){
b.addEventListener("click", function(){
if(!edit) return;
var who = parseInt(b.dataset.who, 10);
var next = edit.time[who] + parseInt(b.dataset.clockAdj, 10) * 1000;
edit.time[who] = Math.min(99 * 60000, Math.max(0, next));
paintScore();
});
});
document.getElementById("score").addEventListener("click", openScore);
document.getElementById("score-done").addEventListener("click", function(){
applyScore();
closeSheet();
});
document.getElementById("done").addEventListener("click", function(){ openSheet(false); });
scrim.addEventListener("click", function(){ openSheet(false); });
btnReset.addEventListener("click", armReset);
btnPlay.addEventListener("click", togglePause);
@@ -818,8 +1105,9 @@
/* ============ go ============ */
applyTheme();
paintSettings();
loadGame(); // restores a match in progress; leaves the fresh state if there's none
paintPlay();
reset();
render();
loop();
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){