Konubinix' opinionated web of thoughts

Score Counter Tally

Fleeting

Result in https://sam.konubinix.eu/tally .

Board games bring people together, but keeping score on paper gets messy fast — erasing, miscounting, losing the sheet. Phone apps exist, but most don’t sync across devices, and many are bloated with ads.

We want something simple: a PWA that works like a shared spreadsheet, players as columns and rounds as rows. It should work offline, sync over WebSocket when connected, and feel native on a phone. Every design choice below follows from these goals.

This document walks through the app the way a player uses it: opening it for the first time, setting up a game, entering scores round by round, correcting mistakes, checking who’s winning, and saving the session for later. The code appears where it serves the story, not grouped by technology layer.

Table of Contents

Starting a game

You open the app on your phone. No players, no scores — just a dice emoji and a “Start Game” button. That empty state is intentional: it signals that this is a blank slate and gently points you toward the first action.

From here you can either tap “+” to quickly add a single player, or tap “Start Game” to open the full setup flow: name the session, pick players from your roster (or create new ones), and go.

What you see first

The empty state disappears as soon as the first player is added. Until then, it’s the only thing on screen.

/* ── Empty state ── */
.empty-state{text-align:center;padding:60px 20px;color:var(--muted)}
.empty-state .emoji{font-size:3rem;margin-bottom:12px}
.empty-state p{font-size:.95rem;line-height:1.5}

The table area hosts both the empty state and the score table. Alpine’s x-show toggles visibility reactively based on $store.game.hasPlayers. Player columns, score rows, the entry bar, and totals are all driven by x-for loops over the reactive game store — no imperative DOM rebuild.

<div class="table-wrap" id="tableWrap">
  <div class="empty-state" id="emptyState" x-show="!$store.game.hasPlayers">
    <div class="emoji">&#x1f3b2;</div>
    <p>No players yet.</p>
    <button class="modal-close" id="btnStartGame" style="margin-top:16px;width:auto;padding:14px 32px" @click="openPickModal()">Start Game</button>
  </div>
  <table id="scoreTable" x-show="$store.game.hasPlayers" x-cloak
    :style="'min-width:' + ($store.game.colWidths.reduce(function(s,v){return s+v},0) + 28) + 'px'">
    <thead>
      <tr id="headerRow">
        <th>#</th>
        <template x-for="(p, i) in $store.game.players" :key="i">
          <th :style="'width:calc((100% - 28px) / ' + $store.game.players.length + ');min-width:' + $store.game.colWidths[i] + 'px;border-bottom:3px solid ' + p.color">
            <div class="player-header">
              <input class="player-name-input" :value="p.name" :data-pidx="i"
                :style="'width:' + Math.min(p.name.length || 1, 12) + 'ch;left:calc(50% - ' + Math.min(p.name.length || 1, 12) / 2 + 'ch * cos(55deg) + 0.375rem * sin(55deg))'"
                x-bind:placeholder="'Player ' + (i+1)" spellcheck="false"
                @input="updatePlayerName(i, $el.value)">
            </div>
            <button class="remove-player" :data-pidx="i" title="Remove"
              @click="removePlayer(i)">&#x2715;</button>
          </th>
        </template>
      </tr>
    </thead>
    <tbody id="scoreBody">
      <template x-for="(r, ri) in $store.game.rounds" :key="'r'+ri">
        <tr>
          <td x-text="ri + 1"></td>
          <template x-for="(v, pi) in r" :key="'c'+ri+'_'+pi">
            <td class="score-cell"
              :class="{ 'positive': v > 0, 'negative': v < 0 }"
              :data-round="ri" :data-player="pi"
              @click="openEdit(ri, pi)"
              x-text="v"></td>
          </template>
        </tr>
      </template>
      <tr class="entry-row" x-show="$store.game.hasPlayers">
        <td>
          <button class="entry-confirm" id="entryConfirm" title="Add round"
            @click="addRound()">+</button>
        </td>
        <template x-for="(p, i) in $store.game.players" :key="'e'+i">
          <td>
            <input type="text" inputmode="none" readonly class="entry-input"
              :data-pidx="i" :id="'entry_' + i" placeholder="–"
              :data-pname="p.name"
              :value="$store.game.entries[i]"
              @click="openNumpad($el)">
          </td>
        </template>
      </tr>
    </tbody>
    <tfoot>
      <tr id="totalRow">
        <td>&#x03a3;</td>
        <template x-for="(t, i) in $store.game.liveTotals" :key="'t'+i">
          <td style="font-weight:800;font-size:1rem"
            :class="{ 'winner': $store.game.players.length > 1 && t === $store.game.liveMaxTotal && t !== 0, 'game-won': $store.game.liveWinners[i] }"
            x-text="t"></td>
        </template>
      </tr>
    </tfoot>
  </table>
</div>

Verifying the blank-slate welcome screen

A first-time visitor should land on a clean slate with a prominent “Start Game” button and no leftover data. This test wipes storage and confirms both the empty-state container and the call-to-action are visible.

def test_empty_state(page):
    """Fresh app shows empty state with Start Game button."""
    clear_state(page)
    assert page.locator("#emptyState").is_visible()
    assert page.locator("#btnStartGame").is_visible()
    print("  PASS: empty state")

Both the empty-state container and the call-to-action must be visible.

assert page.locator("#emptyState").is_visible()
assert page.locator("#btnStartGame").is_visible()

The header bar

The header is always visible: app name, a sync indicator dot (grey when offline, green when connected, red when denied), an inline game-name field, and action buttons for the features described in later sections.

<header>
  <h1><span>T</span>ally</h1>
  <a class="sync-dot" id="syncDot" title="Sync status" onclick="if(document.body.getAttribute('data-ws')==='connected'&&confirm('Clear authorization?')){location.target="_blank" href='/authorizationserver/clear-cookie?resource=ywebsocket/'+_roomName}"></a>
  <input type="text" class="game-name-input" id="gameName" placeholder="Game name&#x2026;" spellcheck="false"
    @input="setGameName($el.value)">
  <span class="build-hash" title="Build">nil</span>
  <div class="header-actions">
    <button class="icon-btn" id="btnInstall" title="Install" style="display:none" @click="installApp()">&#x1f4f2;</button>
    <button class="icon-btn" id="btnScoreboard" title="Scoreboard" @click="openScoreboard()">&#x1f3c6;</button>
    <button class="icon-btn" id="btnLoad" title="Load" @click="openLoadModal()">&#x1f4cb;</button>
    <button class="icon-btn" id="btnExport" title="Export CSV" @click="exportCSV()">&#x1f4e5;</button>
    <button class="icon-btn" id="btnNewGame" title="New Game" @click="newGame()">&#x25b6;</button>
    <button class="icon-btn" id="btnReorder" title="Reorder" x-show="$store.game.players.length > 1" @click="openReorderModal()">&#x2195;</button>
    <button class="icon-btn primary" id="btnAddPlayer" title="Add Player" @click="openAddPlayerModal()">+</button>
  </div>
</header>

/* ── Header ── */
header{
  background:var(--surface);border-bottom:1px solid rgba(255,255,255,.06);
  padding:6px 10px;display:flex;align-items:center;gap:6px;
  flex-shrink:0;position:sticky;top:0;z-index:10;
  backdrop-filter:blur(12px);
}
header h1{font-size:1.1rem;font-weight:800;letter-spacing:-.5px;flex-shrink:0}
header h1 span{color:var(--accent)}
.sync-dot{
  width:14px;height:14px;border-radius:50%;flex-shrink:0;
  background:var(--muted);transition:background .3s;
  border:none;padding:0;cursor:pointer;
}
body[data-ws="connected"] .sync-dot{background:var(--green)}
body[data-ws="denied"] .sync-dot{background:var(--accent)}
.sync-dot.syncing{animation:pulse .4s ease-out}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(78,204,163,.6)}100%{box-shadow:0 0 0 6px transparent}}
.game-name-input{
  flex:1;min-width:0;background:none;border:none;
  border-bottom:1.5px solid rgba(255,255,255,.1);
  color:var(--text);font-family:inherit;font-size:.8rem;font-weight:600;
  padding:2px 2px;outline:none;transition:border-color .15s;
}
.game-name-input:focus{border-color:var(--accent)}
.game-name-input::placeholder{color:var(--muted);font-weight:400}
.header-actions{display:flex;gap:4px;flex-shrink:0}
.icon-btn{
  background:rgba(255,255,255,.08);border:none;color:var(--text);
  width:38px;height:38px;border-radius:10px;cursor:pointer;
  display:grid;place-items:center;font-size:1rem;
  transition:background .15s;flex-shrink:0;
}
.icon-btn:active{background:rgba(255,255,255,.18)}
.icon-btn.primary{background:var(--accent)}
.icon-btn.primary:active{background:#c23350}
.build-hash{font-size:.65rem;color:var(--muted);font-family:monospace;flex-shrink:0}

Checking that the header shows all its pieces

The header is the app’s permanent control surface. This test verifies that the title, the editable game-name input, and every action button (scoreboard, load, export, new game, add player) are present and functional on a fresh page.

def test_header_bar(page):
    """Header shows app title, game name input, and action buttons."""
    clear_state(page)
    assert page.locator("header h1").inner_text() == "Tally"
    gn = page.locator("#gameName")
    assert gn.is_visible()
    gn.fill("Chess")
    assert page.input_value("#gameName") == "Chess"
    for btn_id in ["#btnScoreboard", "#btnLoad", "#btnExport", "#btnNewGame", "#btnAddPlayer"]:
        assert page.locator(btn_id).is_visible(), f"{btn_id} should be visible"
    print("  PASS: header bar")

The app title must read “Tally”.

assert page.locator("header h1").inner_text() == "Tally"

The game-name input should be visible and accept text.

gn = page.locator("#gameName")
assert gn.is_visible()
gn.fill("Chess")
assert page.input_value("#gameName") == "Chess"

Every action button must be present.

for btn_id in ["#btnScoreboard", "#btnLoad", "#btnExport", "#btnNewGame", "#btnAddPlayer"]:
    assert page.locator(btn_id).is_visible(), f"{btn_id} should be visible"

Picking players for a new game

Players come from a persistent roster — a list of names you build up over time so you don’t have to retype “Alice” every Friday night. The “New Game” modal shows the roster as toggleable chips: tap the names who are playing tonight, give the session a name (required, so saved games are identifiable), and hit Start. You can add new players or remove existing ones right from the same modal — each chip has a small cross to delete it, and an input field at the bottom lets you type a new name. Below the roster a “Lowest score wins” checkbox lets you flip the ranking direction for games like golf or hearts.

<!-- Pick Players for Game -->
<div class="modal-overlay" id="modalPick" :class="{ 'open': $store.app.showPick }" @click.self="$store.app.showPick = false">
  <div class="modal">
    <h2>New Game</h2>
    <input type="text" class="game-name-input" id="pickGameName" placeholder="Game name&#x2026;" spellcheck="false" style="width:100%;box-sizing:border-box;margin-bottom:12px"
      @input="updatePickStartEnabled()">
    <div id="pickList" x-show="$store.game.roster.length > 0">
      <div class="pick-chips">
        <template x-for="(p, i) in $store.game.roster" :key="'pk'+i">
          <span class="pick-chip-wrap">
            <button class="pick-chip" :id="'pick_' + i" :data-pname="p.name"
              :class="{ 'selected': $store.app.pickSelected[p.name] }"
              @click="togglePick(p.name)" x-text="p.name"></button>
            <button class="pick-chip-del" :data-pname="p.name" title="Remove from roster"
              @click.stop="deleteFromRoster(i)">&#x2715;</button>
          </span>
        </template>
      </div>
    </div>
    <p id="pickEmpty" x-show="$store.game.roster.length === 0" style="color:var(--muted);text-align:center;padding:16px 0">No players in roster yet.</p>
    <div style="display:flex;gap:8px;margin-top:12px">
      <input type="text" id="rosterInput" placeholder="Add player&#x2026;" spellcheck="false"
        style="flex:1;padding:10px;border-radius:10px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-size:.95rem;font-family:inherit"
        @keydown.enter="addToRoster()">
      <button class="modal-close" id="rosterAdd" style="width:auto;margin:0;padding:10px 20px" @click="addToRoster()">Add</button>
    </div>
    <div id="pickOrderList" class="reorder-list" data-reorder="pickOrder" x-show="$store.app.pickOrder.length > 1" style="margin-top:12px">
      <template x-for="(p, i) in $store.app.pickOrder" :key="'po'+i">
        <div class="reorder-item" :data-idx="i">
          <span class="reorder-grip" aria-label="Drag to reorder"></span>
          <span class="reorder-rank" x-text="i + 1"></span>
          <span class="reorder-name" x-text="p.name"></span>
        </div>
      </template>
    </div>
    <label style="display:flex;align-items:center;gap:8px;margin:12px 0;cursor:pointer">
      <input type="checkbox" id="pickLowWins"> Lowest score wins
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">Score transform (JS)
      <input type="text" id="pickScoreTransform" placeholder="e.g. total > 50 ? 25 : total"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">Winning condition (JS)
      <input type="text" id="pickWinCondition" placeholder="e.g. total === 50"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <div style="margin:8px 0"><button class="preset-btn" id="pickPresetMolkky" @click="applyPickPreset('molkky')">M&#xF6;lkky</button></div>
    <button class="modal-close" id="pickStart" x-show="$store.game.roster.length > 0" @click="startGame()">Start Game</button>
    <button class="modal-close" id="pickCancel" style="background:var(--muted);margin-top:8px" @click="$store.app.showPick = false">Cancel</button>
  </div>
</div>
<!-- Add Player to current game -->
<div class="modal-overlay" id="modalAddPlayer" :class="{ 'open': $store.app.showAddPlayer }" @click.self="$store.app.showAddPlayer = false">
  <div class="modal">
    <h2>Add Player</h2>
    <template x-if="$store.app.showAddPlayer">
    <div id="addPlayerList" x-show="$store.game.availablePlayers.length > 0">
      <div class="pick-chips">
        <template x-for="(p, i) in $store.game.availablePlayers" :key="'ap'+i">
          <button class="pick-chip" :data-pname="p.name"
            @click="addPlayerFromChip(p.name)" x-text="p.name"></button>
        </template>
      </div>
    </div>
    </template>
    <div style="display:flex;gap:8px;margin-top:12px">
      <input type="text" id="addPlayerInput" placeholder="New player name&#x2026;" spellcheck="false"
        style="flex:1;padding:10px;border-radius:10px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-size:.95rem;font-family:inherit"
        @keydown.enter="addPlayerFromInput()">
      <button class="modal-close" id="addPlayerAdd" style="width:auto;margin:0;padding:10px 20px" @click="addPlayerFromInput()">Add</button>
    </div>
    <button class="modal-close" id="addPlayerClose" style="background:var(--muted);margin-top:8px" @click="$store.app.showAddPlayer = false">Cancel</button>
  </div>
</div>

The roster is stored in yRoster (a Yjs array of {name:'...'} objects), separate from the current game’s players. The picker pre-selects whoever is already in the current game so you can quickly re-start with the same group. Adding a new player to the roster automatically selects them — you just typed their name, so you clearly want them in the game. Starting a game clears the board and creates fresh Y.Map entries in yPlayers for each selected name.

// ── Roster helpers ──
function getRoster() {
  var a = [];
  for (var i = 0; i < yRoster.length; i++) {
    var r = yRoster.get(i);
    a.push({ name: r.get ? r.get('name') : r.name });
  }
  return a;
}

function deleteFromRoster(idx) {
  var r = yRoster.get(idx); var name = r.get ? r.get('name') : r.name;
  if (!confirm('Remove ' + name + ' from roster?')) return;
  yRoster.delete(idx, 1);
  syncFromYjs();
}

function addToRoster() {
  var name = $('rosterInput').value.trim();
  if (!name) return;
  // Duplicate check
  for (var i = 0; i < yRoster.length; i++) {
    var ri = yRoster.get(i); if ((ri.get ? ri.get('name') : ri.name) === name) { alert(name + ' already exists'); return; }
  }
  yRoster.push([{ name: name }]);
  var sel = Alpine.store('app').pickSelected;
  sel[name] = true;
  Alpine.store('app').pickSelected = Object.assign({}, sel);
  var order = Alpine.store('app').pickOrder.slice();
  order.push({ name: name });
  Alpine.store('app').pickOrder = order;
  $('rosterInput').value = '';
  syncFromYjs();
  $('rosterInput').focus();
}

// ── Player picker ──
function togglePick(name) {
  var sel = Alpine.store('app').pickSelected;
  var order = Alpine.store('app').pickOrder.slice();
  if (sel[name]) {
    delete sel[name];
    order = order.filter(function(p){ return p.name !== name; });
  } else {
    sel[name] = true;
    order.push({ name: name });
  }
  Alpine.store('app').pickSelected = Object.assign({}, sel);
  Alpine.store('app').pickOrder = order;
}

function openPickModal() {
  var sel = {};
  var order = [];
  for (var i = 0; i < yPlayers.length; i++) {
    var n = yPlayers.get(i).get('name');
    sel[n] = true;
    order.push({ name: n });
  }
  Alpine.store('app').pickSelected = sel;
  Alpine.store('app').pickOrder = order;
  $('pickGameName').value = yMeta.get('gameName') || '';
  $('pickLowWins').checked = !!yMeta.get('lowWins');
  $('pickScoreTransform').value = yMeta.get('scoreTransform') || '';
  $('pickWinCondition').value = yMeta.get('winCondition') || '';
  $('rosterInput').value = '';
  syncFromYjs();
  updatePickStartEnabled();
  Alpine.store('app').showPick = true;
}

function updatePickStartEnabled() {
  var gn = $('pickGameName').value.trim();
  $('pickStart').disabled = !gn;
}
function startGame() {
  var gn = $('pickGameName').value.trim();
  if (!gn) return;
  var names = Alpine.store('app').pickOrder.map(function(p){ return p.name; });
  if (names.length === 0) { alert('Select at least one player'); return; }
  yMeta.set('gameName', gn);
  yMeta.set('lowWins', $('pickLowWins').checked);
  yMeta.set('scoreTransform', $('pickScoreTransform').value);
  yMeta.set('winCondition', $('pickWinCondition').value);
  _cachedTransformFn = null;
  _cachedWinFn = null;
  $('gameName').value = gn;
  yPlayers.delete(0, yPlayers.length);
  yRounds.delete(0, yRounds.length);
  Alpine.store('game').entries = [];
  names.forEach(function(n) {
    var pm = new Y.Map();
    pm.set('name', n);
    yPlayers.push([pm]);
  });
  Alpine.store('app').showPick = false;
  render();
}

Opening a new game triggers the pick modal.

def open_new_game(page):
    page.click("#btnNewGame")
    page.wait_for_selector("#modalPick.open")


def add_roster_player(page, name):
    """Add a player to the roster from the pick modal (must be open)."""
    page.fill("#rosterInput", name)
    page.click("#rosterAdd")

Click the New Game button and wait for the pick modal to appear.

page.click("#btnNewGame")
page.wait_for_selector("#modalPick.open")

Adding a roster player means filling the input and clicking Add.

page.fill("#rosterInput", name)
page.click("#rosterAdd")

Making sure removing a roster player asks for confirmation

Deleting a player from the roster is permanent, so the app shows a confirmation dialog. This test dismisses the dialog once (player stays) then accepts it (player disappears), covering both branches.

def test_roster_deletion(page):
    """Deleting a roster player requires confirmation."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    open_new_game(page)
    add_roster_player(page, "Bob")
    assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()
    page.once("dialog", lambda d: d.dismiss())
    page.locator('.pick-chip-del[data-pname="Bob"]').click()
    assert page.locator('.pick-chip[data-pname="Bob"]').is_visible(), "Bob should still be in roster after dismissing"
    page.once("dialog", lambda d: d.accept())
    page.locator('.pick-chip-del[data-pname="Bob"]').click()
    page.wait_for_function('!document.querySelector(\'.pick-chip[data-pname="Bob"]\')')
    assert not page.locator('.pick-chip[data-pname="Bob"]').count(), "Bob should be removed after accepting"
    page.click("#pickCancel")
    print("  PASS: roster deletion confirmation")

Start with Alice in the game, then open the pick modal and add Bob to the roster so we have a chip to delete.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
open_new_game(page)
add_roster_player(page, "Bob")
assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()

Dismissing the confirmation dialog should leave Bob in the roster.

page.once("dialog", lambda d: d.dismiss())
page.locator('.pick-chip-del[data-pname="Bob"]').click()
assert page.locator('.pick-chip[data-pname="Bob"]').is_visible(), "Bob should still be in roster after dismissing"

Accepting the dialog should remove Bob’s chip entirely.

page.once("dialog", lambda d: d.accept())
page.locator('.pick-chip-del[data-pname="Bob"]').click()
page.wait_for_function('!document.querySelector(\'.pick-chip[data-pname="Bob"]\')')
assert not page.locator('.pick-chip[data-pname="Bob"]').count(), "Bob should be removed after accepting"

You cannot start without naming the game

Every saved game is identified by its name, so the “Start” button stays disabled until the user types one. This test opens the pick modal and checks the button state before and after filling in a name.

def test_new_game_requires_name(page):
    """Start Game button is disabled when game name is empty."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    open_new_game(page)
    assert page.locator("#pickStart").is_disabled()
    page.fill("#pickGameName", "Tarot")
    assert not page.locator("#pickStart").is_disabled()
    page.click("#pickCancel")
    print("  PASS: new game requires name")

With an empty name the button should be disabled; filling one in should enable it.

assert page.locator("#pickStart").is_disabled()
page.fill("#pickGameName", "Tarot")
assert not page.locator("#pickStart").is_disabled()

Walking through a full new-game setup

This is the happy-path end-to-end test for game creation: add two players, open the new-game modal, name the game, and start. It verifies that the header reflects the chosen name and the table has the expected player columns.

def test_new_game_flow(page):
    """Create a new game with name and selected players."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    open_new_game(page)
    page.fill("#pickGameName", "Belote")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    assert page.input_value("#gameName") == "Belote"
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    print("  PASS: new game flow")

Add two players so the roster is populated.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")

Current players are pre-selected. Name the game and start.

open_new_game(page)
page.fill("#pickGameName", "Belote")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

The header should reflect the chosen name and both players should appear as columns.

assert page.input_value("#gameName") == "Belote"
names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"

Adding a roster player without losing the game name

While setting up a new game a user may realise they need to add someone to the roster first. This test checks that adding a roster player inline keeps the pick modal open and preserves the game name that was already typed in.

def test_manage_roster_returns_to_pick(page):
    """Add a player to the roster inline in the pick modal."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    open_new_game(page)
    page.fill("#pickGameName", "BoardNight")
    add_roster_player(page, "Bob")
    assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()
    assert "selected" in (page.locator('.pick-chip[data-pname="Bob"]').get_attribute("class") or ""), \
        "Newly added roster player should be auto-selected"
    assert page.input_value("#pickGameName") == "BoardNight", \
        f"Game name lost after adding roster player: {page.input_value('#pickGameName')}"
    page.click("#pickCancel")
    print("  PASS: manage roster returns to pick modal")

Open the pick modal and type a game name before adding a roster player.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
open_new_game(page)
page.fill("#pickGameName", "BoardNight")

Add Bob to the roster inline.

add_roster_player(page, "Bob")

Bob’s chip should appear already selected and the game name should still be there.

assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()
assert "selected" in (page.locator('.pick-chip[data-pname="Bob"]').get_attribute("class") or ""), \
    "Newly added roster player should be auto-selected"
assert page.input_value("#pickGameName") == "BoardNight", \
    f"Game name lost after adding roster player: {page.input_value('#pickGameName')}"

Choosing the playing order before starting

Once two or more players are selected in the pick modal, an ordered list appears below the chips. Each row has a grip handle () on the left that you drag up or down to set who goes first. The order you choose here becomes the column order in the score table.

def test_reorder_pick_modal(page):
    """Reorder players in the pick modal before starting a game."""
    clear_state(page)
    for name in ["Alice", "Bob", "Charlie"]:
        page.click("#btnAddPlayer")
        page.wait_for_selector("#modalAddPlayer.open")
        add_player_via_modal(page, name)
    open_new_game(page)
    page.fill("#pickGameName", "OrderTest")
    # All three should be selected
    page.wait_for_selector("#pickOrderList")
    # Bob is at index 1 — drag him above Alice (index 0)
    drag_reorder(page, "#pickOrderList", 1, 0)
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    names = get_player_names(page)
    assert names == ["Bob", "Alice", "Charlie"], f"Expected [Bob, Alice, Charlie], got {names}"
    print("  PASS: reorder pick modal")

Add Alice, Bob, Charlie via the roster, open the pick modal with all three selected.

clear_state(page)
for name in ["Alice", "Bob", "Charlie"]:
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, name)
open_new_game(page)
page.fill("#pickGameName", "OrderTest")
# All three should be selected
page.wait_for_selector("#pickOrderList")

Drag Bob (initially at index 1) above Alice to position 0.

# Bob is at index 1 — drag him above Alice (index 0)
drag_reorder(page, "#pickOrderList", 1, 0)
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

The game should start with Bob first, then Alice, then Charlie.

names = get_player_names(page)
assert names == ["Bob", "Alice", "Charlie"], f"Expected [Bob, Alice, Charlie], got {names}"

Pick order tracks selection changes

Toggling a player off should remove them from the order list; toggling them back on should append them at the end.

def test_reorder_pick_tracks(page):
    """Pick order list updates when players are toggled."""
    clear_state(page)
    for name in ["Alice", "Bob"]:
        page.click("#btnAddPlayer")
        page.wait_for_selector("#modalAddPlayer.open")
        add_player_via_modal(page, name)
    open_new_game(page)
    page.wait_for_selector("#pickOrderList")
    page.click('.pick-chip[data-pname="Alice"]')  # deselect
    page.click('.pick-chip[data-pname="Alice"]')  # re-select
    items = page.locator('#pickOrderList .reorder-name')
    order = [items.nth(i).inner_text() for i in range(items.count())]
    assert order == ["Bob", "Alice"], f"Expected [Bob, Alice], got {order}"
    page.click("#pickCancel")
    print("  PASS: reorder pick tracks selection")

Start with Alice and Bob selected in the pick modal.

clear_state(page)
for name in ["Alice", "Bob"]:
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, name)
open_new_game(page)
page.wait_for_selector("#pickOrderList")

Deselect Alice, then re-select her. She should now appear after Bob.

page.click('.pick-chip[data-pname="Alice"]')  # deselect
page.click('.pick-chip[data-pname="Alice"]')  # re-select

Order list should show Bob first, then Alice.

items = page.locator('#pickOrderList .reorder-name')
order = [items.nth(i).inner_text() for i in range(items.count())]
assert order == ["Bob", "Alice"], f"Expected [Bob, Alice], got {order}"

Adding a player mid-game

Someone shows up late, or you forgot to add a player at the start. Tapping “+” in the header opens the Add Player modal, which shows roster members not already in the game as quick-tap chips, plus a text field for a brand-new name. Either way the new player is inserted into the table with zero for every past round so the columns stay aligned.

New names typed here are automatically added to the roster for next time.

// ── Add player to current game ──
function getCurrentPlayerNames() {
  var names = {};
  for (var i = 0; i < yPlayers.length; i++) names[yPlayers.get(i).get('name')] = true;
  return names;
}

function addPlayerToGame(name) {
  if (!name) return;
  var current = getCurrentPlayerNames();
  if (current[name]) return;
  var pm = new Y.Map();
  pm.set('name', name);
  yPlayers.push([pm]);
  for (var i = 0; i < yRounds.length; i++) {
    var r = yRounds.get(i).slice();
    r.push(0);
    yRounds.delete(i, 1);
    yRounds.insert(i, [r]);
  }
  // Also add to roster if not already there
  var inRoster = false;
  for (var j = 0; j < yRoster.length; j++) {
    var rj = yRoster.get(j); if ((rj.get ? rj.get('name') : rj.name) === name) { inRoster = true; break; }
  }
  if (!inRoster) yRoster.push([{ name: name }]);
}

function openAddPlayerModal() {
  syncFromYjs();
  $('addPlayerInput').value = '';
  Alpine.store('app').showAddPlayer = true;
  setTimeout(function() { $('addPlayerInput').focus(); }, 100);
}

function addPlayerFromChip(name) {
  addPlayerToGame(name);
  Alpine.store('app').showAddPlayer = false;
  render();
}

function addPlayerFromInput() {
  var name = $('addPlayerInput').value.trim();
  if (!name) return;
  addPlayerToGame(name);
  Alpine.store('app').showAddPlayer = false;
  render();
}

The add-player modal interaction is a three-step sequence reused across nearly every test.

def add_player_via_modal(page, name):
    """Type a name in the Add Player modal and click Add."""
    page.fill("#addPlayerInput", name)
    page.click("#addPlayerAdd")
    page.wait_for_selector("#modalAddPlayer", state="hidden")

First, type the player name into the input field.

page.fill("#addPlayerInput", name)

Then click the Add button to confirm.

page.click("#addPlayerAdd")

Finally, wait for the modal to close — this signals that the player was accepted and the DOM has updated.

page.wait_for_selector("#modalAddPlayer", state="hidden")

A late arrival joins after the first round

When a player joins after rounds have already been played, the app must back-fill their column with zeros. This test enters one round for Alice, adds Bob, and checks that Bob’s total is zero while Alice’s score is preserved.

def test_add_player_mid_game(page):
    """Add a player in the middle of a game with existing rounds."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    t = get_totals(page)
    assert t == [10, 0], f"Expected [10, 0], got {t}"
    print("  PASS: add player mid-game")

Add Alice and enter one round of 10 points for her.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Now add Bob after the first round has been committed.

page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")

Bob’s past rounds should be back-filled with zeros.

names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
t = get_totals(page)
assert t == [10, 0], f"Expected [10, 0], got {t}"

Tapping a roster chip to add a known player

The Add Player modal shows chips for roster members not already in the game. Tapping a chip should immediately add that player and close the modal, saving the user from retyping a name they’ve used before.

def test_add_player_from_roster_chip(page):
    """Add a roster player via chip in the Add Player modal."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    open_new_game(page)
    page.fill("#pickGameName", "Test")
    page.click('.pick-chip[data-pname="Bob"]')
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    assert get_player_names(page) == ["Alice"]
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    modal = page.locator("#modalAddPlayer")
    assert modal.locator('.pick-chip[data-pname="Bob"]').is_visible()
    assert not modal.locator('.pick-chip[data-pname="Alice"]').count()
    modal.locator('.pick-chip[data-pname="Bob"]').click()
    page.wait_for_selector("#modalAddPlayer", state="hidden")
    names = get_player_names(page)
    assert "Bob" in names
    print("  PASS: add player from roster chip")

Create Alice and Bob so the roster has two entries.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")

Start a new game with only Alice selected — deselect Bob before starting.

open_new_game(page)
page.fill("#pickGameName", "Test")
page.click('.pick-chip[data-pname="Bob"]')
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")
assert get_player_names(page) == ["Alice"]

Open the Add Player modal: Bob should appear as a chip (he’s in the roster but not in the game), and Alice should be absent (already playing). Tapping Bob’s chip should add him immediately.

page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
modal = page.locator("#modalAddPlayer")
assert modal.locator('.pick-chip[data-pname="Bob"]').is_visible()
assert not modal.locator('.pick-chip[data-pname="Alice"]').count()
modal.locator('.pick-chip[data-pname="Bob"]').click()
page.wait_for_selector("#modalAddPlayer", state="hidden")
names = get_player_names(page)
assert "Bob" in names

Typing a brand-new player name

The simplest way to add a player: type a name and press Add. This test makes sure the player appears as a column in the score table right away.

def test_add_player_directly(page):
    """Can add a player by typing a name via the + button."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    names = get_player_names(page)
    assert "Alice" in names, f"Expected Alice in {names}"
    print("  PASS: add player directly")

Open the Add Player modal and type “Alice”.

page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")

Alice should now appear as a column header.

names = get_player_names(page)
assert "Alice" in names, f"Expected Alice in {names}"

Entering scores

This is the core interaction loop. Each round, every player gets a score. You tap the entry cell for a player, a numpad slides up from the bottom, you type the number, and tap the arrow to move to the next player. After the last player, the round is committed and a new entry row appears.

The numpad exists because native mobile keyboards are unreliable for fast numeric entry — they vary by OS, they cover too much screen, and they don’t offer quick +/-5/+/-10 buttons that card games need. The custom numpad gives us full control over the experience.

The score table

The table is a spreadsheet: player names as column headers (each color-coded), one row per round, and a sticky totals row at the bottom. The first column (round numbers) and the header row are sticky so they stay visible when scrolling. Score cells are color-coded: green for positive, red for negative. The leading scorer’s total glows gold.

The layout privileges information density over decoration: tight padding, thin grid lines, no rounded corners on cells. On a small phone screen every pixel counts — a leaner grid means more rows and columns visible at once, less scrolling, and faster scanning.

Alpine’s x-for loops in the HTML template render the table reactively from $store.game — the bridge function syncFromYjs() copies Yjs state into the Alpine store, and Alpine handles the DOM.

// render() is an alias for syncFromYjs() — see js-alpine-store

/* ── Table area ── */
.table-wrap{
  flex:1;overflow:auto;padding:0;
  -webkit-overflow-scrolling:touch;
}
table{
  border-collapse:collapse;
  width:100%;table-layout:fixed;
}
th,td{
  padding:4px 3px;text-align:center;
  border:1px solid rgba(255,255,255,.08);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
th:first-child,td:first-child{
  position:sticky;left:0;z-index:2;
  background:var(--bg);min-width:28px;width:28px;
  color:var(--muted);font-size:.75rem;
}
thead th{
  position:sticky;top:0;z-index:3;
  background:var(--surface);font-size:.8rem;font-weight:700;
  vertical-align:bottom;padding:0;
}
thead th:first-child{z-index:4;background:var(--surface)}
.player-header{
  height:80px;
  position:relative;
}
.player-name-input{
  background:none;border:none;color:var(--text);
  font-family:monospace;font-size:.75rem;font-weight:700;
  outline:none;padding:0;
  position:absolute;bottom:0;
  transform-origin:bottom left;transform:rotate(-55deg);
  white-space:nowrap;overflow:hidden;
}
.player-name-input::placeholder{color:var(--muted)}
.player-name-input:focus{border-bottom:1.5px solid var(--accent)}
th.active-player{background:rgba(233,69,96,.15)}
th.active-player .player-name-input{color:#fff;font-weight:800}
.remove-player{
  font-size:.6rem;color:var(--muted);cursor:pointer;
  background:none;border:none;display:block;margin:0 auto;padding:0;
}
.remove-player:active{color:var(--red)}
.score-cell{
  cursor:pointer;font-variant-numeric:tabular-nums;
  font-weight:500;font-size:.85rem;
}
.positive{color:var(--green)}
.negative{color:var(--red)}
tfoot td{
  font-weight:800;font-size:1rem;
  border-top:2px solid rgba(255,255,255,.15);
  background:var(--surface);position:sticky;bottom:0;z-index:2;
}
tfoot td:first-child{z-index:4;background:var(--surface)}
.winner{color:#ffd700}
.game-won{color:#ffd700;text-shadow:0 0 8px #ffd700}
.preset-btn{padding:6px 14px;border-radius:8px;border:1.5px solid rgba(255,255,255,.15);background:var(--surface);color:var(--text);font-size:.8rem;cursor:pointer}

When the roster grows, fixed-width columns force the user to scroll sideways — awkward on a phone. Player names are rotated −55° in the header so they stack diagonally and columns can be narrow.

Each column only needs to be wide enough to tell its player apart from the others. colWidths() finds the shortest prefix that disambiguates each name, then converts it to pixels: number of distinguishing characters × monospace character width × cos(55°). The cosine projects the rotated text back to horizontal space. For example, among “Samuel”, “Samantha” and “Sabrina”, Samuel needs four characters (“Samu”) and Sabrina only three (“Sab”). Names longer than the prefix are simply clipped — no ellipsis.

Two helpers read the score table state for assertions.

def get_totals(page):
    """Return list of total scores from the footer row."""
    cells = page.locator("#totalRow td:not(:first-child)")
    return [int(cells.nth(i).inner_text()) for i in range(cells.count())]


def get_player_names(page):
    inputs = page.locator(".player-name-input")
    return [inputs.nth(i).input_value() for i in range(inputs.count())]

Read every cell in the totals footer, skipping the first (label) column, and return them as a list of integers.

cells = page.locator("#totalRow td:not(:first-child)")
return [int(cells.nth(i).inner_text()) for i in range(cells.count())]

Collect the current value of each player-name input in header order.

inputs = page.locator(".player-name-input")
return [inputs.nth(i).input_value() for i in range(inputs.count())]

Verifying the table shows player columns and totals

After adding two players the score table must appear with one column per player and a totals row showing zeros. This confirms the basic reactive rendering pipeline from Yjs through Alpine to the DOM.

def test_score_table(page):
    """Score table renders player columns and a totals row."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    assert page.locator("#scoreTable").is_visible()
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    assert page.locator("#totalRow").is_visible()
    t = get_totals(page)
    assert t == [0, 0], f"Expected [0, 0], got {t}"
    print("  PASS: score table")

Add two players so the table becomes visible.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")

The table should be visible with both player columns and a totals row showing zeros.

assert page.locator("#scoreTable").is_visible()
names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
assert page.locator("#totalRow").is_visible()
t = get_totals(page)
assert t == [0, 0], f"Expected [0, 0], got {t}"

The numpad

Tapping any entry input opens a bottom-sheet numpad showing the current player’s name and value. The grid has digit keys, a sign toggle (+/-), backspace, clear, quick increment buttons (-10, -5, +5, +10), and two navigation keys: arrow-right (next player or commit round) and checkmark (close numpad without committing). Tapping anywhere outside the numpad also dismisses it, just like any bottom sheet.

The entry inputs are readonly so the native keyboard never appears.

<div class="numpad" id="numpad" :class="{ 'open': $store.app.showNumpad }">
  <div class="numpad-display"><span class="np-label" id="npLabel"></span><br><span id="npValue">0</span></div>
  <button class="np-del-round" id="npDelRound" style="display:none" onclick="deleteEditRound()">Delete Round</button>
  <div class="numpad-grid">
    <button class="np-btn np-quick" data-delta="-10">-10</button>
    <button class="np-btn np-quick" data-delta="-5">-5</button>
    <button class="np-btn np-quick" data-delta="+5">+5</button>
    <button class="np-btn np-quick" data-delta="+10">+10</button>
    <button class="np-btn" data-key="7">7</button>
    <button class="np-btn" data-key="8">8</button>
    <button class="np-btn" data-key="9">9</button>
    <button class="np-btn" data-key="del">&#x232b;</button>
    <button class="np-btn" data-key="4">4</button>
    <button class="np-btn" data-key="5">5</button>
    <button class="np-btn" data-key="6">6</button>
    <button class="np-btn" data-key="sign">+/&#x2212;</button>
    <button class="np-btn" data-key="1">1</button>
    <button class="np-btn" data-key="2">2</button>
    <button class="np-btn" data-key="3">3</button>
    <button class="np-btn np-green" data-key="next">&#x2192;</button>
    <button class="np-btn" data-key="0">0</button>
    <button class="np-btn" data-key="00">00</button>
    <button class="np-btn" data-key="clear">C</button>
    <button class="np-btn np-accent" data-key="done">&#x2713;</button>
  </div>
</div>

// ── Numpad ──
var npTarget = null; // current entry input element
var npRaw = ''; // raw digit string (without sign)
var npNeg = false;
var npEditMode = false; // true when editing a committed cell
var npReplaceOnKey = false; // first keystroke replaces old value in edit mode
var npEditRound = -1;
var npEditPlayer = -1;
var npHistoryPushed = false; // true when a history entry was pushed for the numpad
var npClosingFromHistory = false; // guard to avoid popstate loop

function npValue() {
  var val = npNeg && npRaw !== '' && npRaw !== '0' ? '-' + npRaw : npRaw;
  return val ? parseInt(val) : 0;
}

function npSync() {
  var val = npNeg && npRaw !== '' && npRaw !== '0' ? '-' + npRaw : npRaw;
  $('npValue').textContent = val || '\u2013';
  if (npEditMode) {
    Alpine.store('game').rounds[npEditRound][npEditPlayer] = val ? parseInt(val) : 0;
  } else if (npTarget) {
    var pi = parseInt(npTarget.dataset.pidx);
    if (npRaw !== '') {
      var n = val ? parseInt(val) : 0;
      Alpine.store('game').entries[pi] = n;
      npTarget.value = n;
    }
  }
}

function syncNumpadPadding(open, scrollTarget) {
  var tw = $('tableWrap');
  var np = $('numpad');
  if (open) {
    np.style.display = 'block';
    var npH = np.offsetHeight;
    tw.style.paddingBottom = npH + 'px';
    np.style.display = '';
    if (scrollTarget) {
      requestAnimationFrame(function() {
        var twRect = tw.getBoundingClientRect();
        var elRect = scrollTarget.getBoundingClientRect();
        var visibleBottom = twRect.bottom - npH;
        if (elRect.bottom > visibleBottom) {
          tw.scrollTop += elRect.bottom - visibleBottom;
        }
        // If the entry row is being edited, scroll to bottom to show totals too
        if (scrollTarget.classList.contains('entry-input')) {
          tw.scrollTop = tw.scrollHeight;
        }
      });
    }
  } else {
    tw.style.paddingBottom = '0';
  }
}

function openNumpad(input) {
  document.querySelectorAll('.score-cell.active-edit').forEach(function(el) { el.classList.remove('active-edit'); });
  npEditMode = false;
  npTarget = input;
  var cur = parseInt(input.value) || 0;
  npNeg = cur < 0;
  npRaw = Math.abs(cur).toString();
  if (cur === 0) npRaw = '';
  $('npLabel').textContent = input.dataset.pname || '';
  npSync();
  document.querySelectorAll('.entry-input').forEach(function(el) { el.classList.remove('active-pad'); });
  input.classList.add('active-pad');
  document.querySelectorAll('th.active-player').forEach(function(el) { el.classList.remove('active-player'); });
  var th = document.querySelectorAll('#headerRow th')[parseInt(input.dataset.pidx) + 1];
  if (th) th.classList.add('active-player');
  $('npDelRound').style.display = 'none';
  Alpine.store('app').showNumpad = true;
  if (!npHistoryPushed) { history.pushState({numpad: true}, ''); npHistoryPushed = true; }
  syncNumpadPadding(true, input);
}

function openNumpadForEdit(ri, pi) {
  npEditMode = true;
  npReplaceOnKey = true;
  npEditRound = ri;
  npEditPlayer = pi;
  npTarget = null;
  var players = Alpine.store('game').players;
  var cur = yRounds.get(ri)[pi];
  npNeg = cur < 0;
  npRaw = Math.abs(cur).toString();
  if (cur === 0) npRaw = '';
  $('npLabel').textContent = (players[pi].name || '') + ' \u2014 R' + (ri + 1);
  document.querySelectorAll('.score-cell.active-edit').forEach(function(el) { el.classList.remove('active-edit'); });
  var cell = document.querySelector('td.score-cell[data-round="' + ri + '"][data-player="' + pi + '"]');
  if (cell) cell.classList.add('active-edit');
  document.querySelectorAll('.entry-input').forEach(function(el) { el.classList.remove('active-pad'); });
  document.querySelectorAll('th.active-player').forEach(function(el) { el.classList.remove('active-player'); });
  var th = document.querySelectorAll('#headerRow th')[pi + 1];
  if (th) th.classList.add('active-player');
  npSync();
  $('npDelRound').style.display = 'block';
  Alpine.store('app').showNumpad = true;
  if (!npHistoryPushed) { history.pushState({numpad: true}, ''); npHistoryPushed = true; }
  syncNumpadPadding(true, cell);
}

function closeNumpad() {
  if (npEditMode) {
    var r = yRounds.get(npEditRound).slice();
    r[npEditPlayer] = npValue();
    yRounds.delete(npEditRound, 1);
    yRounds.insert(npEditRound, [r]);
    document.querySelectorAll('.score-cell.active-edit').forEach(function(el) { el.classList.remove('active-edit'); });
    $('npDelRound').style.display = 'none';
    npEditMode = false;
    npEditRound = -1;
    npEditPlayer = -1;
    render();
  }
  syncNumpadPadding(false);
  Alpine.store('app').showNumpad = false;
  if (npTarget) npTarget.classList.remove('active-pad');
  document.querySelectorAll('th.active-player').forEach(function(el) { el.classList.remove('active-player'); });
  npTarget = null;
  if (npHistoryPushed && !npClosingFromHistory) { npHistoryPushed = false; history.back(); }
  else { npHistoryPushed = false; }
}

window.addEventListener('popstate', function() {
  if (Alpine.store('app').showNumpad) {
    npClosingFromHistory = true;
    closeNumpad();
    npClosingFromHistory = false;
  }
});

document.addEventListener('click', function(e) {
  if (!Alpine.store('app').showNumpad) return;
  if (e.target.closest('#numpad') || e.target.closest('.entry-input') || e.target.closest('.score-cell')) return;
  closeNumpad();
});

$('numpad').onclick = function(e) {
  var btn = e.target.closest('.np-btn');
  if (!btn) return;
  var key = btn.dataset.key;
  var delta = btn.dataset.delta;

  if (delta) {
    npReplaceOnKey = false;
    var cur;
    if (npEditMode) {
      cur = npNeg ? -(parseInt(npRaw) || 0) : (parseInt(npRaw) || 0);
    } else {
      cur = parseInt(npTarget && npTarget.value) || 0;
    }
    cur += parseInt(delta);
    npNeg = cur < 0;
    npRaw = Math.abs(cur).toString();
    npSync();
    return;
  }

  if (key >= '0' && key <= '9') {
    if (npReplaceOnKey) { npRaw = ''; npNeg = false; npReplaceOnKey = false; }
    npRaw += key;
    npSync();
  } else if (key === '00') {
    if (npReplaceOnKey) { npRaw = ''; npNeg = false; npReplaceOnKey = false; }
    npRaw += '00';
    npSync();
  } else if (key === 'del') {
    npReplaceOnKey = false;
    npRaw = npRaw.slice(0, -1);
    npSync();
  } else if (key === 'clear') {
    npReplaceOnKey = false;
    npRaw = '';
    npNeg = false;
    npSync();
  } else if (key === 'sign') {
    npReplaceOnKey = false;
    npNeg = !npNeg;
    npSync();
  } else if (key === 'next') {
    if (npEditMode) { closeNumpad(); return; }
    if (!npTarget) return;
    // Finalize current entry to 0 if nothing was typed
    var pi = parseInt(npTarget.dataset.pidx);
    if (npRaw === '') {
      Alpine.store('game').entries[pi] = 0;
      npTarget.value = 0;
    }
    var idx = pi;
    var next = $('entry_' + (idx + 1));
    if (next) openNumpad(next);
    else { addRound(); }
  } else if (key === 'done') {
    closeNumpad();
  }
};

/* ── Entry row (in-table) ── */
.entry-row td{padding:4px 2px}
.entry-input{
  width:100%;background:rgba(255,255,255,.08);border:1.5px solid rgba(255,255,255,.1);
  border-radius:8px;color:var(--text);font-family:inherit;font-size:1rem;
  font-weight:700;text-align:center;padding:8px 4px;outline:none;
  -moz-appearance:textfield;box-sizing:border-box;
}
.entry-input::-webkit-inner-spin-button,
.entry-input::-webkit-outer-spin-button{-webkit-appearance:none}
.entry-input.active-pad{border-color:var(--accent)}
.entry-confirm{
  width:36px;height:36px;border-radius:10px;border:none;
  background:var(--green);color:#111;font-size:1.2rem;font-weight:800;
  cursor:pointer;display:grid;place-items:center;
}
.entry-confirm:active{background:#3ab88e}
/* ── Numpad ── */
.numpad{
  position:fixed;bottom:0;left:0;right:0;z-index:150;
  background:var(--surface);border-top:1px solid rgba(255,255,255,.1);
  padding:8px;display:none;max-height:100vh;max-height:100dvh;overflow-y:auto;
}
.numpad.open{display:block}
.numpad-display{
  text-align:center;font-size:1.3rem;font-weight:800;color:var(--text);
  padding:6px;margin-bottom:6px;
}
.numpad-display .np-label{font-size:.85rem;color:var(--text);font-weight:700}
.numpad-grid{
  display:grid;grid-template-columns:repeat(4,1fr);gap:6px;
}
.np-btn{
  padding:14px 0;border:none;border-radius:10px;font-size:1.1rem;font-weight:700;
  cursor:pointer;font-family:inherit;color:var(--text);
  background:rgba(255,255,255,.08);
}
.np-btn:active{background:rgba(255,255,255,.18)}
.np-btn.np-accent{background:var(--accent);color:#fff}
.np-btn.np-green{background:var(--green);color:#111}
.np-btn.np-quick{background:rgba(255,255,255,.05);font-size:.85rem}
@media(max-height:400px){
  .numpad{padding:4px}
  .numpad-display{padding:2px;margin-bottom:2px;font-size:1rem}
  .numpad-grid{gap:3px}
  .np-btn{padding:8px 0;font-size:.95rem;border-radius:6px}
}

Two helpers drive the numpad from tests.

def enter_score_via_numpad(page, player_idx, digits):
    """Click the entry input for a player, type digits on the numpad."""
    page.click(f"#entry_{player_idx}")
    page.wait_for_selector("#numpad.open")
    for d in str(digits):
        page.click(f'.np-btn[data-key="{d}"]')


def click_numpad(page, key):
    page.click(f'.np-btn[data-key="{key}"]')

Tap the entry cell for the given player index to open the numpad.

page.click(f"#entry_{player_idx}")
page.wait_for_selector("#numpad.open")

Type each digit by clicking the corresponding numpad button.

for d in str(digits):
    page.click(f'.np-btn[data-key="{d}"]')

A single numpad key press.

page.click(f'.np-btn[data-key="{key}"]')


def drag_reorder(page, container_sel, from_idx, to_idx):
    """Drag a reorder item from one index to another via its grip handle."""
    items = page.locator(f"{container_sel} .reorder-item")
    src = items.nth(from_idx).locator(".reorder-grip")
    dst = items.nth(to_idx).locator(".reorder-grip")
    src.drag_to(dst)

Opening the numpad and typing digits

Tapping an entry input should slide up the numpad, display the current player’s name, and accept digit keys. The display must update live as buttons are pressed, and the done key must close the numpad.

def test_numpad_entry(page):
    """Numpad opens on entry click and accepts digit input."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    assert page.locator("#npLabel").inner_text() == "Alice"
    page.click('.np-btn[data-key="4"]')
    page.click('.np-btn[data-key="2"]')
    assert page.locator("#npValue").inner_text() == "42"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    print("  PASS: numpad entry")

Tap Alice’s entry cell — the numpad should slide up and show her name.

page.click("#entry_0")
page.wait_for_selector("#numpad.open")
assert page.locator("#npLabel").inner_text() == "Alice"

Press 4 then 2 — the display should read “42”.

page.click('.np-btn[data-key="4"]')
page.click('.np-btn[data-key="2"]')
assert page.locator("#npValue").inner_text() == "42"

The done key closes the numpad.

click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")

Back button closes the numpad

On a phone the back gesture is the natural way to dismiss anything that popped up. If the numpad is open and the user swipes back, the numpad should close instead of navigating away from the app.

def test_numpad_back(page):
    """Back button closes the numpad instead of navigating away."""
    clear_state(page)
    url_before = page.url
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    page.evaluate("history.back()")
    page.wait_for_timeout(500)
    try:
        still_here = page.url == url_before
        numpad_hidden = page.query_selector("#numpad.open") is None
    except Exception:
        still_here = False
        numpad_hidden = False
    if not still_here:
        # Navigated away — restore the page for subsequent tests
        page.goto(BASE_URL)
        page.wait_for_selector("#btnAddPlayer")
        raise AssertionError("Back button navigated away instead of closing numpad")
    assert numpad_hidden, "Numpad still open after back button"
    print("  PASS: numpad back")

Tapping outside the numpad closes it

Like any bottom sheet, tapping outside the numpad should dismiss it. Users don’t have to hunt for the done button — a quick tap on the dimmed area above does the trick.

def test_numpad_tap_outside(page):
    """Tapping outside the numpad closes it."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    # Click the header area, well above the numpad
    page.click("#gameName")
    page.wait_for_selector("#numpad.open", state="hidden", timeout=2000)
    print("  PASS: numpad tap outside")

Numpad does not hide the edited row or totals

When many rounds fill the table, opening the numpad must not hide the row being edited. The entry row and the totals footer must remain visible — the table should have enough bottom padding to keep them above the numpad.

def test_numpad_visibility(page):
    """Entry row and totals stay visible above the numpad with many rounds."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    # Add 12 rounds to fill the table
    for r in range(12):
        enter_score_via_numpad(page, 0, str(r + 1))
        click_numpad(page, "next")
        page.wait_for_selector('#entry_1.active-pad')
        for d in str(r + 1):
            page.click(f'.np-btn[data-key="{d}"]')
        click_numpad(page, "next")  # commits
        page.wait_for_selector("#numpad.open")
        click_numpad(page, "done")
    # Open numpad on the entry row
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    page.wait_for_timeout(200)
    np_box = page.locator("#numpad").bounding_box()
    entry_box = page.locator("#entry_0").bounding_box()
    total_box = page.locator("#totalRow").bounding_box()
    assert entry_box["y"] + entry_box["height"] <= np_box["y"], \
        f"Entry row bottom ({entry_box['y'] + entry_box['height']}) overlaps numpad top ({np_box['y']})"
    assert total_box["y"] + total_box["height"] <= np_box["y"], \
        f"Total row bottom ({total_box['y'] + total_box['height']}) overlaps numpad top ({np_box['y']})"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    # Edit a middle row — that cell must be visible above the numpad
    cell = page.locator('td.score-cell[data-round="5"][data-player="0"]')
    cell.click()
    page.wait_for_selector("#numpad.open")
    page.wait_for_timeout(200)
    np_box = page.locator("#numpad").bounding_box()
    cell_box = cell.bounding_box()
    assert cell_box["y"] + cell_box["height"] <= np_box["y"], \
        f"Edited cell bottom ({cell_box['y'] + cell_box['height']}) overlaps numpad top ({np_box['y']})"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    print("  PASS: numpad visibility")

The entry row and totals footer must sit above the numpad. We check by comparing bounding boxes: the bottom edge of the element must be above the top edge of the numpad.

np_box = page.locator("#numpad").bounding_box()
entry_box = page.locator("#entry_0").bounding_box()
total_box = page.locator("#totalRow").bounding_box()
assert entry_box["y"] + entry_box["height"] <= np_box["y"], \
    f"Entry row bottom ({entry_box['y'] + entry_box['height']}) overlaps numpad top ({np_box['y']})"
assert total_box["y"] + total_box["height"] <= np_box["y"], \
    f"Total row bottom ({total_box['y'] + total_box['height']}) overlaps numpad top ({np_box['y']})"

When editing a past round, the clicked cell must be visible above the numpad.

np_box = page.locator("#numpad").bounding_box()
cell_box = cell.bounding_box()
assert cell_box["y"] + cell_box["height"] <= np_box["y"], \
    f"Edited cell bottom ({cell_box['y'] + cell_box['height']}) overlaps numpad top ({np_box['y']})"

Active player column is highlighted while entering a score

On a small screen with many players it is hard to tell whose score you are editing. When the numpad is open the active player’s column header lights up so the eye is immediately drawn to the right name. The highlight must follow the cursor when pressing “next” and disappear once the numpad closes.

def test_active_player_highlight(page):
    """Column header highlights the player being scored."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    # Open numpad on Alice — her header must gain the highlight
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    ths = page.locator("#headerRow th")
    assert "active-player" in (ths.nth(1).get_attribute("class") or ""), \
        "Alice header should be highlighted"
    assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
        "Bob header should not be highlighted"
    # Press next → highlight moves to Bob
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
        "Alice header should no longer be highlighted"
    assert "active-player" in (ths.nth(2).get_attribute("class") or ""), \
        "Bob header should now be highlighted"
    # Close numpad → no highlight
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
        "Alice header should not be highlighted after close"
    assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
        "Bob header should not be highlighted after close"
    print("  PASS: active player highlight")

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
# Open numpad on Alice — her header must gain the highlight
page.click("#entry_0")
page.wait_for_selector("#numpad.open")
ths = page.locator("#headerRow th")
assert "active-player" in (ths.nth(1).get_attribute("class") or ""), \
    "Alice header should be highlighted"
assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
    "Bob header should not be highlighted"
# Press next → highlight moves to Bob
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
    "Alice header should no longer be highlighted"
assert "active-player" in (ths.nth(2).get_attribute("class") or ""), \
    "Bob header should now be highlighted"
# Close numpad → no highlight
click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")
assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
    "Alice header should not be highlighted after close"
assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
    "Bob header should not be highlighted after close"

Entering a negative score with the sign toggle

Some games penalise players with negative points. The numpad has a +/− toggle for this. This test enters a digit, flips the sign, and confirms the total reflects the negative value.

def test_negative_score(page):
    """Enter a negative score via the sign toggle."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    enter_score_via_numpad(page, 0, "7")
    click_numpad(page, "sign")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    t = get_totals(page)
    assert t == [-7], f"Expected [-7], got {t}"
    print("  PASS: negative score")

Enter 7 then flip the sign to make it negative, and commit the round.

enter_score_via_numpad(page, 0, "7")
click_numpad(page, "sign")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

The total should be -7.

t = get_totals(page)
assert t == [-7], f"Expected [-7], got {t}"

Committing a round

When the last player’s score is entered and you tap “next”, or when you tap the “+” button in the entry row, the round is committed. All entry values are collected into an array and pushed into yRounds. A safety prompt fires if every score is zero (probably an accidental tap). After adding, the table scrolls to the bottom and the numpad opens on the first player’s input for the next round.

// ── Round management ──
function addRound(){
  var g = Alpine.store('game');
  if(yPlayers.length === 0){ alert('Add players first!'); return; }
  var scores = g.entries.map(function(v){ return v || 0; });
  var anyNonZero = scores.some(function(v){ return v !== 0; });
  if(!anyNonZero && !confirm('All scores are 0. Add this round?')) return;
  closeNumpad();
  yRounds.push([scores]);
  for(var i = 0; i < g.entries.length; i++){
    g.entries[i] = '';
    var inp = $('entry_' + i);
    if(inp) inp.value = '';
  }
  render();
  var winners = g.liveWinners;
  var winnerNames = [];
  for(var wi = 0; wi < g.players.length; wi++){
    if(winners[wi]) winnerNames.push(g.players[wi].name);
  }
  if(winnerNames.length > 0){
    showWinToast(winnerNames);
    setTimeout(function(){ openScoreboard(); }, 300);
    return;
  }
  setTimeout(function(){
    $('tableWrap').scrollTop = $('tableWrap').scrollHeight;
    var first = $('scoreBody').querySelector('.entry-input');
    if (first) openNumpad(first);
  }, 50);
}

The entry bar markup used to be a separate fixed element at the bottom, but it’s now rendered as the last row of the table body (the .entry-row) so it scrolls with the data and the numpad sits below it.

<div class="entry-bar" id="entryBar" style="display:none"></div>

A complete round from numpad to totals

This is the core scoring workflow: tap a player’s entry cell, punch in digits on the numpad, press “next” to advance to the next player, and finish the round. The test verifies that the totals row matches the entered values.

def test_score_entry(page):
    """Enter scores for a round and verify totals."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")  # commits the round
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    t = get_totals(page)
    assert t == [10, 20], f"Expected [10, 20], got {t}"
    print("  PASS: score entry")

Start with two players.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")

Enter 10 for Alice, press next to advance to Bob, enter 20, and press next again to commit the round.

enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")  # commits the round
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Totals should reflect 10 and 20.

t = get_totals(page)
assert t == [10, 20], f"Expected [10, 20], got {t}"

Entry row resets after each round

After a round is committed the entry row must return to a clean slate so the next round starts fresh — otherwise leftover digits from the previous round create confusion.

def test_entry_reset(page):
    """After committing a round, every entry input must be zero/empty."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")  # commits the round
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # After committing, entry inputs must show 0 or empty
    for i in range(2):
        val = page.locator(f"#entry_{i}").input_value()
        assert val in ("", "0"), f"Entry {i} not reset: got '{val}'"
    print("  PASS: entry reset")

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")  # commits the round
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# After committing, entry inputs must show 0 or empty
for i in range(2):
    val = page.locator(f"#entry_{i}").input_value()
    assert val in ("", "0"), f"Entry {i} not reset: got '{val}'"

Untouched entries look different from a real zero

When a round starts, the entry cells should look visually empty — a quiet placeholder () signals “not yet entered” without being mistaken for the number zero. Once the player opens the numpad and confirms without typing anything, the entry becomes a real 0 and displays as such. The green arrow (next / commit) must still work on untouched entries, treating them as zero.

def test_placeholder_vs_zero(page):
    """Untouched entries show placeholder; confirmed-empty shows 0."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    # Before touching anything, entries must be empty (placeholder visible)
    page.wait_for_selector("#entry_1")
    page.wait_for_function("document.getElementById('entry_0').value === ''")
    def entry_val(idx):
        return page.evaluate(f"document.getElementById('entry_{idx}').value")
    for i in range(2):
        val = entry_val(i)
        assert val == "", f"Untouched entry {i} should be empty, got '{val}'"
        ph = page.locator(f"#entry_{i}").get_attribute("placeholder")
        assert ph == "–", f"Placeholder should be '–', got '{ph}'"
    # Open numpad for Alice, type 5, press next → moves to Bob
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    # Alice's entry shows the typed score; Bob's entry still empty (numpad open but nothing typed)
    assert entry_val(0) == "5", f"Alice entry should be '5', got '{entry_val(0)}'"
    assert entry_val(1) == "", f"Bob entry should still be empty, got '{entry_val(1)}'"
    # Press next on Bob without typing → finalizes Bob to 0 and commits the round
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # After commit, both entries reset to empty (placeholder)
    page.wait_for_function("document.getElementById('entry_0').value === '' || document.getElementById('entry_0').value === '0'")
    page.wait_for_function("document.getElementById('entry_1').value === ''")
    print("  PASS: placeholder vs zero")

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
# Before touching anything, entries must be empty (placeholder visible)
page.wait_for_selector("#entry_1")
page.wait_for_function("document.getElementById('entry_0').value === ''")
def entry_val(idx):
    return page.evaluate(f"document.getElementById('entry_{idx}').value")
for i in range(2):
    val = entry_val(i)
    assert val == "", f"Untouched entry {i} should be empty, got '{val}'"
    ph = page.locator(f"#entry_{i}").get_attribute("placeholder")
    assert ph == "–", f"Placeholder should be '–', got '{ph}'"
# Open numpad for Alice, type 5, press next → moves to Bob
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
# Alice's entry shows the typed score; Bob's entry still empty (numpad open but nothing typed)
assert entry_val(0) == "5", f"Alice entry should be '5', got '{entry_val(0)}'"
assert entry_val(1) == "", f"Bob entry should still be empty, got '{entry_val(1)}'"
# Press next on Bob without typing → finalizes Bob to 0 and commits the round
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# After commit, both entries reset to empty (placeholder)
page.wait_for_function("document.getElementById('entry_0').value === '' || document.getElementById('entry_0').value === '0'")
page.wait_for_function("document.getElementById('entry_1').value === ''")

Totals update as you type

Players want instant feedback while punching in digits — the totals row should reflect what the score would be if the round were committed right now, not lag behind until you press next.

def test_live_totals(page):
    """Totals must update in real time during numpad entry."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    # Before any entry, totals should be [0, 0]
    t = get_totals(page)
    assert t == [0, 0], f"Expected [0, 0] initially, got {t}"
    # Start entering a score for Alice without committing
    enter_score_via_numpad(page, 0, "15")
    # Totals should already reflect the pending entry
    t = get_totals(page)
    assert t == [15, 0], f"Expected [15, 0] during entry, got {t}"
    click_numpad(page, "done")
    print("  PASS: live totals")

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
# Before any entry, totals should be [0, 0]
t = get_totals(page)
assert t == [0, 0], f"Expected [0, 0] initially, got {t}"
# Start entering a score for Alice without committing
enter_score_via_numpad(page, 0, "15")
# Totals should already reflect the pending entry
t = get_totals(page)
assert t == [15, 0], f"Expected [15, 0] during entry, got {t}"
click_numpad(page, "done")

Correcting mistakes

Mistakes happen mid-game: you fat-finger a score, record the wrong player, or realize the whole round was bogus. The app needs to handle all of these gracefully without forcing you to restart.

Editing a score

Tap any committed score cell and it lights up with an accent outline while the numpad slides into view, pre-filled with the current value. Edit the number with the same keys you use for new entries, then press done — no modal, no context switch. A “Delete Round” button appears in the numpad when editing, in case the whole round needs to go.

Under the hood, editing replaces the value at yRounds[round][player] by splicing the round array. Deleting a round removes it from yRounds entirely.

<!-- Edit Cell: now uses inline numpad (no modal) -->

// ── Edit cell ──
function openEdit(ri, pi){
  openNumpadForEdit(ri, pi);
}

function deleteEditRound(){
  if(!npEditMode) return;
  if(!confirm('Delete round ' + (npEditRound+1) + '?')) return;
  var ri = npEditRound;
  document.querySelectorAll('.score-cell.active-edit').forEach(function(el) { el.classList.remove('active-edit'); });
  $('npDelRound').style.display = 'none';
  npEditMode = false;
  npEditRound = -1;
  npEditPlayer = -1;
  Alpine.store('app').showNumpad = false;
  npTarget = null;
  yRounds.delete(ri, 1);
  render();
}

/* ── Edit cell highlight ── */
.score-cell.active-edit{
  outline:2px solid var(--accent);
  outline-offset:-2px;
  background:rgba(224,176,255,.1);
}
.np-del-round{
  display:block;margin:0 auto 6px;padding:8px 16px;border-radius:8px;
  background:rgba(255,255,255,.08);border:none;color:var(--muted);
  font-size:.8rem;font-weight:600;cursor:pointer;font-family:inherit;
}
.np-del-round:active{color:var(--red)}

Correcting a score after the round is committed

Mistakes happen — tapping a committed score cell highlights it and opens the numpad so you can correct the value inline. This test enters a round, edits the cell to a different number via the numpad, and checks that the total updates accordingly.

def test_edit_score(page):
    """Edit an existing score cell."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click('td.score-cell[data-round="0"][data-player="0"]')
    page.wait_for_selector("#numpad.open")
    assert page.query_selector('td.score-cell.active-edit') is not None
    click_numpad(page, "1")
    click_numpad(page, "5")
    # Total updates in real time, before committing
    t = get_totals(page)
    assert t == [15], f"Expected live total [15] during edit, got {t}"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    t = get_totals(page)
    assert t == [15], f"Expected [15], got {t}"
    # Tap a past cell to start editing, then tap an entry cell
    page.click('td.score-cell[data-round="0"][data-player="0"]')
    page.wait_for_selector("#numpad.open")
    assert page.query_selector('td.score-cell.active-edit') is not None
    page.click("#entry_0")
    page.wait_for_timeout(100)
    assert page.query_selector('td.score-cell.active-edit') is None, "Edit highlight not cleared when switching to entry"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    print("  PASS: edit score")

Add Alice and commit a round with 5 points.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Tap the committed cell — the numpad slides up and the cell is highlighted. The first digit replaces the old value, so typing 1 then 5 gives 15 (not 515). Before pressing done, the total should already read 15 — scores update in real time as you type.

page.click('td.score-cell[data-round="0"][data-player="0"]')
page.wait_for_selector("#numpad.open")
assert page.query_selector('td.score-cell.active-edit') is not None
click_numpad(page, "1")
click_numpad(page, "5")
# Total updates in real time, before committing
t = get_totals(page)
assert t == [15], f"Expected live total [15] during edit, got {t}"
click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")

After pressing done the total is committed and still reads 15.

t = get_totals(page)
assert t == [15], f"Expected [15], got {t}"
# Tap a past cell to start editing, then tap an entry cell
page.click('td.score-cell[data-round="0"][data-player="0"]')
page.wait_for_selector("#numpad.open")
assert page.query_selector('td.score-cell.active-edit') is not None
page.click("#entry_0")
page.wait_for_timeout(100)
assert page.query_selector('td.score-cell.active-edit') is None, "Edit highlight not cleared when switching to entry"
click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")

Removing a player

Each player column has a small “x remove” button below the name. A confirmation dialog prevents accidental taps. Removing a player deletes their Y.Map from yPlayers and splices their index out of every round (cleaning up empty rounds that result).

// ── Player management ──
function removePlayer(idx){
  var pm = yPlayers.get(idx);
  if(!confirm('Remove ' + pm.get('name') + '?')) return;
  yPlayers.delete(idx, 1);
  for(var i=0;i<yRounds.length;i++){
    var r = yRounds.get(i).slice();
    r.splice(idx, 1);
    yRounds.delete(i, 1);
    if(r.length > 0) yRounds.insert(i, [r]);
    else { i--; }
  }
  render();
}

Kicking a player out of the game

Removing a player deletes their column from the score table. A confirmation dialog prevents accidental taps. This test checks both the dialog and the DOM cleanup.

def test_remove_player(page):
    """Remove a player from the game."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    assert len(get_player_names(page)) == 2
    page.on("dialog", lambda d: d.accept())
    page.click('button.remove-player[data-pidx="0"]')
    names = get_player_names(page)
    assert names == ["Bob"], f"Expected [Bob], got {names}"
    print("  PASS: remove player")

Start with two players.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
assert len(get_player_names(page)) == 2

Accept the confirmation dialog and remove Alice — only Bob should remain.

page.on("dialog", lambda d: d.accept())
page.click('button.remove-player[data-pidx="0"]')
names = get_player_names(page)
assert names == ["Bob"], f"Expected [Bob], got {names}"

Columns compress when the roster grows

With two or three players the table is spacious — but add five or six and the fixed min-width forces horizontal scrolling. The diagonal headers and per-column sizing should shrink columns enough to fit. This test adds six players whose names share a common prefix and checks that the table still fits inside the viewport without requiring a sideways scroll.

def test_columns_compress(page):
    """Many players should not cause horizontal scrolling."""
    clear_state(page)
    for name in ["Samuel", "Samantha", "Sabrina", "Sandra", "Sarah", "Sylvie"]:
        page.click("#btnAddPlayer")
        page.wait_for_selector("#modalAddPlayer.open")
        add_player_via_modal(page, name)
    wrap = page.locator(".table-wrap")
    sw = wrap.evaluate("el => el.scrollWidth")
    cw = wrap.evaluate("el => el.clientWidth")
    assert sw <= cw, f"table overflows: scrollWidth {sw} > clientWidth {cw}"
    # The # column must stay narrow
    hash_w = page.evaluate("document.querySelector('#headerRow th').offsetWidth")
    assert hash_w <= 36, f"# column too wide: {hash_w}px"
    print("  PASS: columns compress")

Start fresh and add six players whose names share a long prefix.

clear_state(page)
for name in ["Samuel", "Samantha", "Sabrina", "Sandra", "Sarah", "Sylvie"]:
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, name)

The table wrapper must not scroll horizontally: its scroll width should equal its visible width.

wrap = page.locator(".table-wrap")
sw = wrap.evaluate("el => el.scrollWidth")
cw = wrap.evaluate("el => el.clientWidth")
assert sw <= cw, f"table overflows: scrollWidth {sw} > clientWidth {cw}"
# The # column must stay narrow
hash_w = page.evaluate("document.querySelector('#headerRow th').offsetWidth")
assert hash_w <= 36, f"# column too wide: {hash_w}px"

Changing the playing order

In many tabletop games the seating order matters — it decides who plays after whom. Sometimes you realise mid-game that the order is wrong, or two people swap seats. The reorder button (&#x2195;) in the header opens a modal where you drag players by their grip handle () to set the new order. Confirming rearranges the columns and moves every historical score so that each player’s data follows them.

// ── Reorder players ──
function openReorderModal() {
  var list = getPlayers().map(function(p){ return { name: p.name }; });
  Alpine.store('app').reorderList = list;
  Alpine.store('app').showReorder = true;
}

function confirmReorder() {
  var newOrder = Alpine.store('app').reorderList;
  var oldNames = getPlayers().map(function(p){ return p.name; });
  // Build permutation: perm[newIdx] = oldIdx
  var perm = newOrder.map(function(p) {
    return oldNames.indexOf(p.name);
  });
  // Apply to yPlayers: delete all and re-create in new order
  yPlayers.delete(0, yPlayers.length);
  perm.forEach(function(oldIdx) {
    var pm = new Y.Map();
    pm.set('name', oldNames[oldIdx]);
    yPlayers.push([pm]);
  });
  // Apply to yRounds: permute each round
  for (var i = 0; i < yRounds.length; i++) {
    var old = yRounds.get(i);
    var newRow = perm.map(function(oldIdx){ return old[oldIdx] || 0; });
    yRounds.delete(i, 1);
    yRounds.insert(i, [newRow]);
  }
  // Permute pending entries
  var g = Alpine.store('game');
  var oldEntries = g.entries.slice();
  g.entries = perm.map(function(oldIdx){ return oldEntries[oldIdx] || 0; });
  Alpine.store('app').showReorder = false;
  render();
}

// ── Wire shared reorder engine to Alpine stores ──
// The engine (shared_blocks.org:js-reorder-engine) fires =reorder:move=
// on the list when the user drags an item across slots. We update the
// corresponding =Alpine.store('app')[dataset.reorder]= array, Alpine
// re-renders the x-for.
document.addEventListener('reorder:move', function(e) {
  var list = e.target.closest('.reorder-list');
  if (!list) return;
  var key = list.dataset.reorder;
  if (!key) return;
  var arr = Alpine.store('app')[key].slice();
  var moved = arr.splice(e.detail.from, 1)[0];
  arr.splice(e.detail.to, 0, moved);
  Alpine.store('app')[key] = arr;
});

<!-- Reorder Players -->
<div class="modal-overlay" id="modalReorder" :class="{ 'open': $store.app.showReorder }" @click.self="$store.app.showReorder = false">
  <div class="modal">
    <h2>Player Order</h2>
    <div class="reorder-list" data-reorder="reorderList">
      <template x-for="(p, i) in $store.app.reorderList" :key="'ro'+i">
        <div class="reorder-item" :data-idx="i">
          <span class="reorder-grip" aria-label="Drag to reorder"></span>
          <span class="reorder-rank" x-text="i + 1"></span>
          <span class="reorder-name" x-text="p.name"></span>
        </div>
      </template>
    </div>
    <button class="modal-close" id="reorderConfirm" @click="confirmReorder()">Apply</button>
    <button class="modal-close" id="reorderCancel" style="background:var(--muted);margin-top:8px" @click="$store.app.showReorder = false">Cancel</button>
  </div>
</div>

Le CSS du drag-and-drop (.reorder-list, .reorder-item, .drag-clone, .reorder-grip) vit dans shared_blocks.org (section Reorder engine) et est inclus via <<shared_blocks.org:css-reorder-ex()>> dans le root block, avec l’engine JS.

Reordering players mid-game

After entering a round of scores, the reorder button lets you drag players into a new order. The test enters one round (10 for Alice, 20 for Bob), then drags Bob above Alice and checks that the names and totals follow suit.

def test_reorder_mid_game(page):
    """Reorder players mid-game and verify scores follow."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnReorder")
    page.wait_for_selector("#modalReorder.open")
    # Drag Bob (index 1) above Alice (index 0)
    drag_reorder(page, "#modalReorder .reorder-list", 1, 0)
    page.click("#reorderConfirm")
    page.wait_for_selector("#modalReorder", state="hidden")
    names = get_player_names(page)
    assert names == ["Bob", "Alice"], f"Expected [Bob, Alice], got {names}"
    t = get_totals(page)
    assert t == [20, 10], f"Expected [20, 10], got {t}"
    print("  PASS: reorder mid-game")

Start with Alice and Bob, enter a round of scores.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Open the reorder modal and drag Bob above Alice (from index 1 to index 0).

page.click("#btnReorder")
page.wait_for_selector("#modalReorder.open")
# Drag Bob (index 1) above Alice (index 0)
drag_reorder(page, "#modalReorder .reorder-list", 1, 0)
page.click("#reorderConfirm")
page.wait_for_selector("#modalReorder", state="hidden")

Bob should now be first with his score of 20, Alice second with 10.

names = get_player_names(page)
assert names == ["Bob", "Alice"], f"Expected [Bob, Alice], got {names}"
t = get_totals(page)
assert t == [20, 10], f"Expected [20, 10], got {t}"

Scores survive reordering across multiple rounds

With three players and two rounds already committed, reordering must permute every historical row so that the totals remain correct.

def test_reorder_preserves_scores(page):
    """Reordering preserves scores across multiple rounds."""
    clear_state(page)
    for name in ["Alice", "Bob", "Charlie"]:
        page.click("#btnAddPlayer")
        page.wait_for_selector("#modalAddPlayer.open")
        add_player_via_modal(page, name)
    # Round 1: 10, 20, 30
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    for d in "30": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # Round 2: 5, 15, 25
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "15": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    for d in "25": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnReorder")
    page.wait_for_selector("#modalReorder.open")
    # Drag Charlie (index 2) to the top (index 0)
    drag_reorder(page, "#modalReorder .reorder-list", 2, 0)
    page.click("#reorderConfirm")
    page.wait_for_selector("#modalReorder", state="hidden")
    names = get_player_names(page)
    assert names == ["Charlie", "Alice", "Bob"], f"Expected [Charlie, Alice, Bob], got {names}"
    t = get_totals(page)
    assert t == [55, 15, 35], f"Expected [55, 15, 35], got {t}"
    print("  PASS: reorder preserves scores")

Set up three players with two rounds: round 1 = [10, 20, 30], round 2 = [5, 15, 25]. Totals before reorder: [15, 35, 55].

clear_state(page)
for name in ["Alice", "Bob", "Charlie"]:
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, name)
# Round 1: 10, 20, 30
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
for d in "30": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# Round 2: 5, 15, 25
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "15": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
for d in "25": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Drag Charlie (index 2) to the top (index 0). New order should be Charlie, Alice, Bob → totals [55, 15, 35].

page.click("#btnReorder")
page.wait_for_selector("#modalReorder.open")
# Drag Charlie (index 2) to the top (index 0)
drag_reorder(page, "#modalReorder .reorder-list", 2, 0)
page.click("#reorderConfirm")
page.wait_for_selector("#modalReorder", state="hidden")

Totals must reflect the new column order.

names = get_player_names(page)
assert names == ["Charlie", "Alice", "Bob"], f"Expected [Charlie, Alice, Bob], got {names}"
t = get_totals(page)
assert t == [55, 15, 35], f"Expected [55, 15, 35], got {t}"

Reorder button hidden with a single player

Reordering makes no sense with fewer than two players, so the button should be invisible in that case.

def test_reorder_button_hidden(page):
    """Reorder button is hidden when fewer than two players."""
    clear_state(page)
    assert not page.locator("#btnReorder").is_visible(), "should be hidden with no players"
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    assert not page.locator("#btnReorder").is_visible(), "should be hidden with one player"
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    page.wait_for_selector("#btnReorder", state="visible")
    assert page.locator("#btnReorder").is_visible(), "should be visible with two players"
    print("  PASS: reorder button hidden")

Who’s winning?

Two mechanisms give instant feedback on the game state.

The totals row at the bottom of the table always shows cumulative scores. When there are two or more players, the leader’s total is highlighted in gold — a subtle but satisfying visual cue.

The scoreboard modal (trophy button in the header) shows all players ranked by score, with gold/silver/bronze styling for the podium. This is the “end of game” view: a quick way to see the final standings without counting columns.

Not every game rewards the highest score. In golf or hearts the winner is the player with the fewest points. A “Lowest score wins” checkbox in the game-setup modal flips the logic: the totals row highlights the minimum instead of the maximum, and the scoreboard sorts ascending. The flag is stored alongside the game name in the shared yMeta map, so every device sees the same ranking direction.

The scoreboard sorts players by total score and renders ranked rows with gold/silver/bronze styling for the top 3. When two players are tied they share the same rank and podium colour. Tapping the trophy button builds the list on the fly from the current Yjs state.

<!-- Scoreboard Modal -->
<div class="modal-overlay" id="modalScoreboard" :class="{ 'open': $store.app.showScoreboard }" @click.self="$store.app.showScoreboard = false">
  <div class="modal">
    <h2>&#x1f3c6; Results</h2>
    <div id="scoreboardList">
      <template x-for="(e, i) in $store.game.scoreboardEntries" :key="'sb'+i">
        <div class="modal-row">
          <div>
            <span class="rank" :class="{ gold: e.rank===0, silver: e.rank===1, bronze: e.rank===2 }"
              x-text="'#' + (e.rank+1)"></span>
            <span class="modal-name" x-text="e.name"></span>
          </div>
          <span class="modal-score" x-text="e.score"></span>
        </div>
      </template>
      <p x-show="$store.game.players.length === 0" style="color:var(--muted);text-align:center;padding:20px">No players yet.</p>
    </div>
    <label style="display:flex;align-items:center;gap:8px;margin:12px 0;cursor:pointer;font-size:.9rem">
      <input type="checkbox" id="scoreboardLowWins"
        :checked="$store.game.lowWins"
        @change="toggleLowWins($el.checked)"> Lowest score wins
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">Score transform (JS)
      <input type="text" id="scoreTransformInput" placeholder="e.g. total > 50 ? 25 : total"
        :value="$store.game.scoreTransform"
        @change="setScoreTransform($el.value)"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">Winning condition (JS)
      <input type="text" id="winConditionInput" placeholder="e.g. total === 50"
        :value="$store.game.winCondition"
        @change="setWinCondition($el.value)"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <div style="margin:8px 0"><button class="preset-btn" id="presetMolkky" @click="applyPreset('molkky')">M&#xF6;lkky</button></div>
    <button class="modal-close" id="closeScoreboard" @click="$store.app.showScoreboard = false">Close</button>
  </div>
</div>

// ── Scoreboard ──
function openScoreboard(){
  Alpine.store('app').showScoreboard = true;
}
function toggleLowWins(checked){
  yMeta.set('lowWins', checked);
  render();
}
function setScoreTransform(v){
  yMeta.set('scoreTransform', v);
  _cachedTransformFn = null;
  render();
}
function setWinCondition(v){
  yMeta.set('winCondition', v);
  _cachedWinFn = null;
  render();
}
function applyPreset(preset){
  if(preset === 'molkky'){
    $('scoreTransformInput').value = 'total > 50 ? 25 : total';
    $('winConditionInput').value = 'total === 50';
    setScoreTransform('total > 50 ? 25 : total');
    setWinCondition('total === 50');
  }
}
function applyPickPreset(preset){
  if(preset === 'molkky'){
    $('pickScoreTransform').value = 'total > 50 ? 25 : total';
    $('pickWinCondition').value = 'total === 50';
  }
}
function showWinToast(names){
  var el = $('winToast');
  var verb = names.length > 1 ? ' win!' : ' wins!';
  el.textContent = '\uD83C\uDFC6 ' + names.join(', ') + verb;
  el.classList.add('show');
  setTimeout(function(){ el.classList.remove('show'); }, 4000);
}

Checking the scoreboard ranks players correctly

The scoreboard ranks players by total score, highest first. This catches sorting regressions and verifies the modal renders player names next to their totals.

def test_scoreboard(page):
    """Scoreboard shows players sorted by score."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "15":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    rows = page.locator(".modal-row")
    first_name = rows.nth(0).locator(".modal-name").inner_text()
    assert first_name == "Bob", f"Expected Bob first, got {first_name}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard")

Give Alice 5 and Bob 15 so Bob should rank first.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "15":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Open the scoreboard modal.

page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")

Bob (15) should be ranked above Alice (5).

rows = page.locator(".modal-row")
first_name = rows.nth(0).locator(".modal-name").inner_text()
assert first_name == "Bob", f"Expected Bob first, got {first_name}"

Tied players share the same podium rank

When two players finish with the same score they should share the same rank and podium styling — both gold if they are tied for first, both silver if tied for second, and so on.

def test_scoreboard_tie(page):
    """Tied players share the same rank and podium class."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Carol")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    enter_score_via_numpad(page, 2, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    rows = page.locator(".modal-row")
    for idx in range(2):
        rank_el = rows.nth(idx).locator(".rank")
        rank_text = rank_el.inner_text()
        assert rank_text == "#1", f"Player {idx} rank should be #1, got {rank_text}"
        rank_cls = rank_el.get_attribute("class") or ""
        assert "gold" in rank_cls, f"Player {idx} should have gold class, got {rank_cls}"
    carol_rank = rows.nth(2).locator(".rank")
    assert carol_rank.inner_text() == "#2", f"Carol should be #2, got {carol_rank.inner_text()}"
    carol_cls = carol_rank.get_attribute("class") or ""
    assert "silver" in carol_cls, f"Carol should have silver class, got {carol_cls}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard tie")

Give Alice, Bob, and Carol scores of 10, 10, and 5 then open the scoreboard.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Carol")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
enter_score_via_numpad(page, 2, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")

Alice and Bob (both 10) should display #1 with the gold class. Carol (5) comes right after the tied pair, so she should be #2 with silver — not #3 with bronze.

rows = page.locator(".modal-row")
for idx in range(2):
    rank_el = rows.nth(idx).locator(".rank")
    rank_text = rank_el.inner_text()
    assert rank_text == "#1", f"Player {idx} rank should be #1, got {rank_text}"
    rank_cls = rank_el.get_attribute("class") or ""
    assert "gold" in rank_cls, f"Player {idx} should have gold class, got {rank_cls}"
carol_rank = rows.nth(2).locator(".rank")
assert carol_rank.inner_text() == "#2", f"Carol should be #2, got {carol_rank.inner_text()}"
carol_cls = carol_rank.get_attribute("class") or ""
assert "silver" in carol_cls, f"Carol should have silver class, got {carol_cls}"

Low-wins mode flips ranking and highlight

Some games reward the lowest score — golf, for instance, or hearts. When the “Lowest score wins” toggle is on during game setup, the scoreboard should rank the player with the fewest points first, and the totals row should highlight that player’s column in gold.

def test_scoreboard_low_wins(page):
    """Low-wins mode ranks lowest score first and highlights it."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    open_new_game(page)
    page.fill("#pickGameName", "Golf")
    page.check("#pickLowWins")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    enter_score_via_numpad(page, 0, "15")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_cls = total_cells.nth(0).get_attribute("class") or ""
    bob_cls = total_cells.nth(1).get_attribute("class") or ""
    assert "winner" in bob_cls, f"Expected Bob to be winner, classes: {bob_cls}"
    assert "winner" not in alice_cls, f"Alice should not be winner, classes: {alice_cls}"
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    rows = page.locator(".modal-row")
    first_name = rows.nth(0).locator(".modal-name").inner_text()
    assert first_name == "Bob", f"Expected Bob first in low-wins, got {first_name}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard low-wins")

Start a new game with “Lowest score wins” checked. We reuse the roster already present from earlier tests, then create a fresh game via the pick modal.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
open_new_game(page)
page.fill("#pickGameName", "Golf")
page.check("#pickLowWins")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Give Alice 15 and Bob 5 so Bob has the lower — and winning — score.

enter_score_via_numpad(page, 0, "15")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Bob’s total column (index 1) should carry the winner class, not Alice’s.

total_cells = page.locator("#totalRow td:not(:first-child)")
alice_cls = total_cells.nth(0).get_attribute("class") or ""
bob_cls = total_cells.nth(1).get_attribute("class") or ""
assert "winner" in bob_cls, f"Expected Bob to be winner, classes: {bob_cls}"
assert "winner" not in alice_cls, f"Alice should not be winner, classes: {alice_cls}"

And the scoreboard should rank Bob (5) above Alice (15).

page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
rows = page.locator(".modal-row")
first_name = rows.nth(0).locator(".modal-name").inner_text()
assert first_name == "Bob", f"Expected Bob first in low-wins, got {first_name}"

Toggling low-wins from the podium

You might realise mid-game that you picked the wrong scoring direction. The scoreboard modal includes the same “Lowest score wins” toggle so you can flip it without restarting. The ranking and the totals-row highlight update immediately.

def test_scoreboard_toggle_low_wins(page):
    """Toggling low-wins in the scoreboard flips ranking live."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "15":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.check("#scoreboardLowWins")
    rows = page.locator(".modal-row")
    first_name = rows.nth(0).locator(".modal-name").inner_text()
    assert first_name == "Alice", f"Expected Alice first after toggle, got {first_name}"
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_cls = total_cells.nth(0).get_attribute("class") or ""
    assert "winner" in alice_cls, f"Alice should be winner after toggle, classes: {alice_cls}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard toggle low-wins")

We reuse the same setup as the regular scoreboard test: Alice has 5 and Bob has 15, so in the default high-wins mode Bob ranks first. Opening the scoreboard and checking “Lowest score wins” should flip the order — Alice (5) moves to first place.

page.check("#scoreboardLowWins")

rows = page.locator(".modal-row")
first_name = rows.nth(0).locator(".modal-name").inner_text()
assert first_name == "Alice", f"Expected Alice first after toggle, got {first_name}"
total_cells = page.locator("#totalRow td:not(:first-child)")
alice_cls = total_cells.nth(0).get_attribute("class") or ""
assert "winner" in alice_cls, f"Alice should be winner after toggle, classes: {alice_cls}"

Custom rules

Some games have special scoring rules that go beyond “add it up”. In Mölkky, for instance, a player whose total exceeds 50 drops back to 25, and the first to land on exactly 50 wins. Rather than hardcoding every game’s quirks, the app exposes two plain-JavaScript expressions that you can set per game:

  • Score transform — applied to each player’s running total after every round. Example: total > 50 ? 25 : total implements the Mölkky reset.
  • Winning condition — evaluated for each player after every round. When it returns a truthy value, that player is declared the winner. Example: total == 50=.

Both fields accept any JavaScript expression. The variable total holds the player’s current cumulative score, playerIndex their column position, and round the current round number (zero-based).

The expressions live in two textareas that appear both in the game-setup modal and in the scoreboard modal, so you can set them at the start or adjust mid-game. They are persisted in yMeta and synced across devices like every other setting.

When a winning condition fires after a completed round, the winner’s total cell receives a .game-won class, a toast banner announces the winner by name, and the scoreboard modal opens automatically.

<div id="winToast" class="win-toast"></div>

.win-toast{
  position:fixed;top:0;left:0;right:0;
  padding:16px;text-align:center;font-size:1.1rem;font-weight:700;
  background:linear-gradient(135deg,#ffd700,#ff8c00);color:#1a1a2e;
  transform:translateY(-100%);transition:transform .3s ease;
  z-index:9999;
}
.win-toast.show{transform:translateY(0)}

Score transform resets totals exceeding a threshold

A Mölkky-style transform resets a player to 25 whenever their score goes past 50. This test enters two rounds of scores that push a player over the limit and checks that the total reflects the reset.

def test_score_transform(page):
    """Score transform applies cumulatively per round."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    # Set transform via scoreboard modal
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.fill("#scoreTransformInput", "total > 50 ? 25 : total")
    page.click("#closeScoreboard")
    # Round 1: Alice 30, Bob 10
    enter_score_via_numpad(page, 0, "30")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # Round 2: Alice 25 (total would be 55, resets to 25), Bob 10
    enter_score_via_numpad(page, 0, "25")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_total = int(total_cells.nth(0).inner_text())
    bob_total = int(total_cells.nth(1).inner_text())
    assert alice_total == 25, f"Alice total should be 25, got {alice_total}"
    assert bob_total == 20, f"Bob total should be 20, got {bob_total}"
    print("  PASS: score transform")

Set up two players, configure the transform via JavaScript, enter two rounds so that Alice’s total exceeds 50.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
# Set transform via scoreboard modal
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
page.fill("#scoreTransformInput", "total > 50 ? 25 : total")
page.click("#closeScoreboard")
# Round 1: Alice 30, Bob 10
enter_score_via_numpad(page, 0, "30")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# Round 2: Alice 25 (total would be 55, resets to 25), Bob 10
enter_score_via_numpad(page, 0, "25")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Alice’s total should be 25 (reset from 55), Bob’s should be 20.

total_cells = page.locator("#totalRow td:not(:first-child)")
alice_total = int(total_cells.nth(0).inner_text())
bob_total = int(total_cells.nth(1).inner_text())
assert alice_total == 25, f"Alice total should be 25, got {alice_total}"
assert bob_total == 20, f"Bob total should be 20, got {bob_total}"

Winning condition highlights the victor

When a player’s total satisfies the winning condition expression, the app marks them as the winner. This test sets a condition of total == 50= and verifies the winner class appears.

def test_win_condition(page):
    """Winning condition marks the winner."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    # Set winning condition via scoreboard modal
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.fill("#winConditionInput", "total === 50")
    page.click("#closeScoreboard")
    # Round 1: Alice 50, Bob 10
    enter_score_via_numpad(page, 0, "50")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    # Committing the round triggers the win — numpad won't reopen
    click_numpad(page, "next")
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_cls = total_cells.nth(0).get_attribute("class") or ""
    assert "game-won" in alice_cls, f"Alice should have game-won class, got: {alice_cls}"
    bob_cls = total_cells.nth(1).get_attribute("class") or ""
    assert "game-won" not in bob_cls, f"Bob should not have game-won class, got: {bob_cls}"
    # Toast should appear with winner name
    toast = page.locator("#winToast")
    toast.wait_for(state="visible", timeout=3000)
    toast_text = toast.inner_text()
    assert "Alice" in toast_text, f"Toast should mention Alice, got: {toast_text}"
    # Scoreboard should have opened automatically (after 300ms delay)
    page.wait_for_selector("#modalScoreboard.open", timeout=3000)
    assert modal_is_open(page, "modalScoreboard"), "Scoreboard should auto-open on win"
    page.click("#closeScoreboard")
    print("  PASS: win condition")

Set up a game with Alice and Bob, configure a winning condition, and enter scores so that Alice reaches exactly 50.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
# Set winning condition via scoreboard modal
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
page.fill("#winConditionInput", "total === 50")
page.click("#closeScoreboard")
# Round 1: Alice 50, Bob 10
enter_score_via_numpad(page, 0, "50")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
# Committing the round triggers the win — numpad won't reopen
click_numpad(page, "next")

Alice’s total cell should have the game-won class, a toast should appear with her name, and the scoreboard modal should open automatically.

total_cells = page.locator("#totalRow td:not(:first-child)")
alice_cls = total_cells.nth(0).get_attribute("class") or ""
assert "game-won" in alice_cls, f"Alice should have game-won class, got: {alice_cls}"
bob_cls = total_cells.nth(1).get_attribute("class") or ""
assert "game-won" not in bob_cls, f"Bob should not have game-won class, got: {bob_cls}"
# Toast should appear with winner name
toast = page.locator("#winToast")
toast.wait_for(state="visible", timeout=3000)
toast_text = toast.inner_text()
assert "Alice" in toast_text, f"Toast should mention Alice, got: {toast_text}"
# Scoreboard should have opened automatically (after 300ms delay)
page.wait_for_selector("#modalScoreboard.open", timeout=3000)
assert modal_is_open(page, "modalScoreboard"), "Scoreboard should auto-open on win"
page.click("#closeScoreboard")

Rules persist through game setup

Rules set in the setup modal should be saved to yMeta and visible in the scoreboard modal after the game starts.

def test_rules_in_setup(page):
    """Rules set in new-game modal persist and appear in scoreboard."""
    clear_state(page)
    # Add a player so the header buttons appear
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    # Open new game modal via header button
    open_new_game(page)
    add_roster_player(page, "Bob")
    page.fill("#pickGameName", "Molkky")
    # Alice is already pre-selected from the current game, just select Bob too
    page.click('.pick-chip[data-pname="Bob"]')
    page.fill("#pickScoreTransform", "total > 50 ? 25 : total")
    page.fill("#pickWinCondition", "total === 50")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick:not(.open)", state="attached")
    # Verify rules are in scoreboard
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    transform_val = page.input_value("#scoreTransformInput")
    assert transform_val == "total > 50 ? 25 : total", f"Transform not saved: {transform_val}"
    win_val = page.input_value("#winConditionInput")
    assert win_val == "total === 50", f"Win condition not saved: {win_val}"
    page.click("#closeScoreboard")
    print("  PASS: rules in setup")

clear_state(page)
# Add a player so the header buttons appear
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
# Open new game modal via header button
open_new_game(page)
add_roster_player(page, "Bob")
page.fill("#pickGameName", "Molkky")
# Alice is already pre-selected from the current game, just select Bob too
page.click('.pick-chip[data-pname="Bob"]')
page.fill("#pickScoreTransform", "total > 50 ? 25 : total")
page.fill("#pickWinCondition", "total === 50")
page.click("#pickStart")
page.wait_for_selector("#modalPick:not(.open)", state="attached")
# Verify rules are in scoreboard
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
transform_val = page.input_value("#scoreTransformInput")
assert transform_val == "total > 50 ? 25 : total", f"Transform not saved: {transform_val}"
win_val = page.input_value("#winConditionInput")
assert win_val == "total === 50", f"Win condition not saved: {win_val}"
page.click("#closeScoreboard")

Saving and resuming

Games often span multiple sessions — you pause for dinner, continue tomorrow, or want to look up last week’s scores. The app auto-saves the current game whenever you start a new one, and lets you explicitly load any past game from an archive.

Save and load

Saving snapshots the current players, rounds, and game name into yGames. Each entry gets a unique ID and timestamp. Loading a saved game removes it from the archive (it becomes the active session), so re-saving never creates a duplicate. Loading also handles backward compatibility for an older format that stored cumulative scores instead of per-round deltas.

<!-- Saved Games Modal -->
<div class="modal-overlay" id="modalGames" :class="{ 'open': $store.app.showGames }" @click.self="$store.app.showGames = false">
  <div class="modal">
    <h2>&#x1f4cb; Saved Games</h2>
    <div id="gamesList">
      <p x-show="$store.game.savedGames.length === 0" class="no-games">No saved games.</p>
      <template x-for="(g, ri) in $store.game.savedGames" :key="'sg'+ri">
        <div class="game-entry" :data-gidx="g._idx">
          <div class="game-entry-header">
            <span class="game-entry-name" x-text="g.gameName"></span>
            <span class="game-entry-date" x-text="g._dateStr"></span>
            <button class="game-entry-load" :data-gloadidx="g._idx"
              @click="loadGame(g._idx)">&#x25b6;</button>
            <button class="game-entry-delete" :data-gdelidx="g._idx"
              @click="deleteGame(g._idx)">&#x1f5d1;</button>
          </div>
          <div class="game-entry-players" x-text="g._summary"></div>
        </div>
      </template>
    </div>
    <button class="modal-close" id="closeGames" @click="$store.app.showGames = false">Close</button>
  </div>
</div>

// ── Save ──
function saveGame(){
  if(yPlayers.length === 0) return;
  var players = getPlayers();
  var rounds = getRounds();
  var t = totals();
  var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  yGames.push([{
    id: id,
    date: new Date().toISOString(),
    gameName: yMeta.get('gameName') || 'Untitled',
    lowWins: !!yMeta.get('lowWins'),
    scoreTransform: yMeta.get('scoreTransform') || '',
    winCondition: yMeta.get('winCondition') || '',
    players: players.map(function(p,i){ return {name:p.name, score:t[i]}; }),
    rounds: JSON.parse(JSON.stringify(rounds))
  }]);
}

// ── Load ──
function openLoadModal(){
  syncFromYjs();
  Alpine.store('app').showGames = true;
}

.game-entry{
  padding:14px;background:rgba(255,255,255,.05);border-radius:12px;
  margin-bottom:8px;cursor:pointer;transition:background .12s;
}
.game-entry:active{background:rgba(255,255,255,.1)}
.game-entry-header{
  display:flex;justify-content:space-between;align-items:center;
  margin-bottom:6px;
}
.game-entry-name{font-weight:700;font-size:.95rem}
.game-entry-date{font-size:.7rem;color:var(--muted)}
.game-entry-players{font-size:.8rem;color:var(--muted);line-height:1.5}
.game-entry-winner{color:#ffd700;font-weight:600}
.game-entry-load,.game-entry-delete{
  background:none;border:none;color:var(--muted);cursor:pointer;
  font-size:.85rem;padding:4px 8px;border-radius:6px;
  transition:background .15s,color .15s;
}
.game-entry-load:hover{background:var(--green);color:#fff}
.game-entry-delete:hover{background:var(--red);color:#fff}
.no-games{text-align:center;color:var(--muted);padding:20px;font-size:.9rem}

Saving a session and loading it back

Starting a new game auto-saves the previous one. This test creates a game with a known name and score, starts a second game, then loads the first back and checks that name, players, and scores are faithfully restored.

def test_save_and_load_game(page):
    """Save a game and load it back."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.evaluate("document.getElementById('gameName').value = 'SaveTest';"
                  "document.getElementById('gameName').dispatchEvent(new Event('input'));")
    enter_score_via_numpad(page, 0, "42")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "NewGame")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    entries = page.locator(".game-entry")
    assert entries.count() >= 1, "Expected at least one saved game"
    assert "SaveTest" in entries.first.inner_text()
    page.on("dialog", lambda d: d.accept())
    entries.first.locator(".game-entry-load").click()
    page.wait_for_selector("#modalGames", state="hidden")
    assert page.input_value("#gameName") == "SaveTest"
    t = get_totals(page)
    assert t == [42], f"Expected [42], got {t}"
    print("  PASS: save and load game")

Create a game named “SaveTest” with Alice scoring 42.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.evaluate("document.getElementById('gameName').value = 'SaveTest';"
              "document.getElementById('gameName').dispatchEvent(new Event('input'));")
enter_score_via_numpad(page, 0, "42")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Start a new game — this auto-saves the current one.

open_new_game(page)
page.fill("#pickGameName", "NewGame")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Load the saved game and verify the name and scores are restored.

page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
entries = page.locator(".game-entry")
assert entries.count() >= 1, "Expected at least one saved game"
assert "SaveTest" in entries.first.inner_text()
page.on("dialog", lambda d: d.accept())
entries.first.locator(".game-entry-load").click()
page.wait_for_selector("#modalGames", state="hidden")
assert page.input_value("#gameName") == "SaveTest"
t = get_totals(page)
assert t == [42], f"Expected [42], got {t}"

Starting a new game

The “New Game” button auto-saves the current game (if any players exist), then opens the player pick modal for a fresh start.

// ── New Game ──
function newGame(){
  if(yPlayers.length > 0){
    saveGame();
  }
  openPickModal();
}

The old game is auto-saved when you start a new one

Users should never lose data by starting a new game. This test creates a game with scores, starts a fresh game, then opens the saved games list and verifies the old game appears there.

def test_new_game_saves(page):
    """Starting a new game auto-saves the current one."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.fill("#gameName", "OldGame")
    page.locator("#gameName").dispatch_event("input")
    enter_score_via_numpad(page, 0, "42")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "NewGame")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    entries = page.locator(".game-entry")
    assert entries.count() >= 1, "Expected at least one saved game"
    assert "OldGame" in entries.first.inner_text(), "Saved game should contain OldGame"
    page.click("#closeGames")
    print("  PASS: starting a new game saves the old one")

Create a game named “OldGame” with a score for Alice.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.fill("#gameName", "OldGame")
page.locator("#gameName").dispatch_event("input")
enter_score_via_numpad(page, 0, "42")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Start a fresh game — the old one should be auto-saved.

open_new_game(page)
page.fill("#pickGameName", "NewGame")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Open the saved games list and verify “OldGame” appears.

page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
entries = page.locator(".game-entry")
assert entries.count() >= 1, "Expected at least one saved game"
assert "OldGame" in entries.first.inner_text(), "Saved game should contain OldGame"
page.click("#closeGames")

Resuming a saved game does not duplicate it

A common flow is to load an old game, play a few more rounds, then move on. The app should update the existing archive entry rather than creating a second copy. This test saves a game, loads it back, adds a round, starts a new game (which auto-saves), and checks that only one entry with that name exists in the list.

def test_resume_no_duplicate(page):
    """Loading a saved game and re-saving does not create a duplicate."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.fill("#gameName", "ResumeMe")
    page.locator("#gameName").dispatch_event("input")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "Temp")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    page.on("dialog", lambda d: d.accept())
    entries = page.locator(".game-entry")
    entries.first.locator(".game-entry-load").click()
    page.wait_for_selector("#modalGames", state="hidden")
    assert page.input_value("#gameName") == "ResumeMe"
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "Final")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    all_entries = page.locator(".game-entry")
    count = 0
    for i in range(all_entries.count()):
        if "ResumeMe" in all_entries.nth(i).inner_text():
            count += 1
    assert count == 1, f"Expected 1 'ResumeMe' entry, found {count}"
    page.click("#closeGames")
    print("  PASS: resuming a saved game does not duplicate it")

Create a game named “ResumeMe”, enter a score, then start a new game so “ResumeMe” lands in the archive.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.fill("#gameName", "ResumeMe")
page.locator("#gameName").dispatch_event("input")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
open_new_game(page)
page.fill("#pickGameName", "Temp")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Load “ResumeMe” back from the archive.

page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
page.on("dialog", lambda d: d.accept())
entries = page.locator(".game-entry")
entries.first.locator(".game-entry-load").click()
page.wait_for_selector("#modalGames", state="hidden")
assert page.input_value("#gameName") == "ResumeMe"

Add another round so the game has changed since loading.

enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Start yet another game (auto-saving “ResumeMe”) and verify that the archive contains exactly one entry named “ResumeMe”.

open_new_game(page)
page.fill("#pickGameName", "Final")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")
page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
all_entries = page.locator(".game-entry")
count = 0
for i in range(all_entries.count()):
    if "ResumeMe" in all_entries.nth(i).inner_text():
        count += 1
assert count == 1, f"Expected 1 'ResumeMe' entry, found {count}"
page.click("#closeGames")

Starting a new game resets the scores

When users start a fresh game, the slate must be clean — no leftover totals from the previous session. This test creates a game, enters a score, then starts a new game and verifies every total is zero.

def test_new_game_resets_scores(page):
    """Starting a new game zeroes out all scores."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    enter_score_via_numpad(page, 0, "42")
    click_numpad(page, "done")
    t = get_totals(page)
    assert t == [42], f"Expected [42] before new game, got {t}"
    open_new_game(page)
    page.fill("#pickGameName", "Fresh")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    t = get_totals(page)
    assert all(v == 0 for v in t), f"Expected all-zero totals after new game, got {t}"
    val = page.locator("#entry_0").input_value()
    assert val in ("", "0"), f"Entry not reset: got '{val}'"
    print("  PASS: new game resets scores")

Create a game with Alice, enter a score in the entry row (without committing the round) so the live total is non-zero.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
enter_score_via_numpad(page, 0, "42")
click_numpad(page, "done")
t = get_totals(page)
assert t == [42], f"Expected [42] before new game, got {t}"

Start a fresh game with the same player.

open_new_game(page)
page.fill("#pickGameName", "Fresh")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Every total and entry input should now be zero.

t = get_totals(page)
assert all(v == 0 for v in t), f"Expected all-zero totals after new game, got {t}"
val = page.locator("#entry_0").input_value()
assert val in ("", "0"), f"Entry not reset: got '{val}'"

Exporting to CSV

For people who want their data outside the app — in a spreadsheet, for a league tracker, or just for the record. Generates a CSV with player names as headers, one row per round, and a TOTAL row.

// ── Export CSV ──
function exportCSV(){
  if(yPlayers.length === 0){ alert('Nothing to export.'); return; }
  var players = getPlayers();
  var rounds = getRounds();
  var t = totals();
  var lines = [];
  lines.push(['Round'].concat(players.map(function(p){ return '"'+p.name.replace(/"/g,'""')+'"'; })).join(','));
  rounds.forEach(function(r, ri){
    lines.push([ri+1].concat(r).join(','));
  });
  lines.push(['TOTAL'].concat(t).join(','));
  var blob = new Blob([lines.join('\n')], {type:'text/csv;charset=utf-8;'});
  var a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = (yMeta.get('gameName') || 'scores') + '.csv';
  a.click();
  URL.revokeObjectURL(a.href);
};

Exporting produces a well-formed CSV file

The export button triggers a CSV download containing all rounds and totals. This test intercepts the download, parses the content, and checks that the header row, score rows, and total row match the game state.

def test_export_csv(page):
    """Export CSV produces correct content."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    page.fill("#gameName", "TestCSV")
    page.locator("#gameName").dispatch_event("input")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    with page.expect_download() as dl_info:
        page.click("#btnExport")
    dl = dl_info.value
    assert "TestCSV" in dl.suggested_filename, f"Filename should contain TestCSV, got {dl.suggested_filename}"
    content = dl.path().read_text()
    lines = content.strip().split("\n")
    assert len(lines) == 3, f"Expected 3 lines (header, round, total), got {len(lines)}"
    assert "Alice" in lines[0] and "Bob" in lines[0], f"Header should have player names: {lines[0]}"
    assert lines[1].startswith("1,"), f"Round row should start with 1: {lines[1]}"
    assert "TOTAL" in lines[2], f"Last row should be TOTAL: {lines[2]}"
    print("  PASS: export CSV")

Create a game named “TestCSV” with Alice (10) and Bob (20).

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")
page.fill("#gameName", "TestCSV")
page.locator("#gameName").dispatch_event("input")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Intercept the download triggered by the export button.

with page.expect_download() as dl_info:
    page.click("#btnExport")
dl = dl_info.value
assert "TestCSV" in dl.suggested_filename, f"Filename should contain TestCSV, got {dl.suggested_filename}"
content = dl.path().read_text()
lines = content.strip().split("\n")

The CSV should have a header row with player names, one round row, and a TOTAL row.

assert len(lines) == 3, f"Expected 3 lines (header, round, total), got {len(lines)}"
assert "Alice" in lines[0] and "Bob" in lines[0], f"Header should have player names: {lines[0]}"
assert lines[1].startswith("1,"), f"Round row should start with 1: {lines[1]}"
assert "TOTAL" in lines[2], f"Last row should be TOTAL: {lines[2]}"

Playing together

The app uses Yjs CRDTs to sync state across devices over WebSocket. Authorization is handled via a magic link: the admin generates a link with clk authorizationserver generate, the user clicks it, and the server sets a cookie and redirects to the app. From then on all changes propagate in real time. The sync dot in the header turns green when connected.

Pour generer un code d’autorisation :

clk authorizationserver generate --redirect 'https://ipfs.konubinix.eu/p/<cid>?websocket_url=wss://konubinix.eu/ywebsocket' ywebsocket/tally sam-phone

Sync initialization

The shared setupSync helper from PWA score apps — shared blocks wires up WebSocket connection, remote-change rendering, and initial render. The sync dot color is driven by the data-ws body attribute set by that shared block.

// ── Init ──
setupSync(persistence, ydoc, render);

Shared blocks sw and manifest are defined in PWA score apps — shared blocks and loaded via the Library of Babel (see local variables at end of file).

Plumbing

Everything below is infrastructure that supports the user-facing features above: the data layer, event wiring, visual foundation, and PWA lifecycle.

Data layer

The Yjs document (ydoc) is the single source of truth, shared with the card-based Tally app (tally_score_tracker.org). Both apps read and write the same shared types:

  • currentPlayers (Y.Array of Y.Maps): each Y.Map has name
  • rounds (Y.Array of arrays): each inner array holds one delta per player
  • savedGames (Y.Array): archive of finished games
  • meta (Y.Map): game-level metadata (currently just gameName)
  • sc_roster (Y.Array of plain objects): persistent player roster (score counter only)

Scores are derived by summing rounds per player column. No score or history fields on players — concurrent round appends merge correctly via Y.Array.

IndexeddbPersistence keeps a local copy in the browser’s IndexedDB so the app works fully offline.

The room name defaults to tally but can be overridden via the room query parameter (e.g. ?room=debug). This is handy for debugging: opening the app at https://example.com/?room=debug creates an isolated session that won’t interfere with a real game in progress.

import Alpine from 'alpinejs';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebsocketProvider } from 'y-websocket';

// ── Yjs setup (shared with tally_score_tracker) ──
var _roomName = new URLSearchParams(location.search).get('room') || 'tally';
var ydoc = new Y.Doc();
var persistence = new IndexeddbPersistence(_roomName, ydoc);
var yPlayers = ydoc.getArray('currentPlayers'); // Y.Map {name}
var yRounds  = ydoc.getArray('rounds');         // [[d0, d1, ...], ...]
var yGames   = ydoc.getArray('savedGames');     // [{id,date,gameName,players,rounds}]
var yRoster  = ydoc.getArray('sc_roster');      // [{name:'...'}] persistent player roster
var yMeta    = ydoc.getMap('meta');              // gameName
var COLORS   = ['#e94560','#4ecca3','#533483','#f9a826','#3fc1c9','#fc5185','#8dc6ff','#6c5ce7'];
var MAX_PLAYERS = 8;

window.ydoc = ydoc;
window.Y = Y;

// ── DOM refs ──
var $ = function(id){ return document.getElementById(id); };

// ── Helpers ──
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;'); }

function getPlayers(){
  var a = [];
  for(var i=0;i<yPlayers.length;i++) a.push({name: yPlayers.get(i).get('name')});
  return a;
}
function getRounds(){
  var a = [];
  for(var i=0;i<yRounds.length;i++) a.push(yRounds.get(i));
  return a;
}
function getSavedGames(){
  var a = [];
  for(var i=0;i<yGames.length;i++){
    var v = yGames.get(i);
    a.push(v && typeof v.toJSON === 'function' ? v.toJSON() : v);
  }
  return a;
}

// ── Custom rule caches ──
var _cachedTransformFn = null;
var _cachedTransformExpr = '';
var _cachedWinFn = null;
var _cachedWinExpr = '';

function getTransformFn(expr){
  if(!expr) return null;
  if(expr === _cachedTransformExpr && _cachedTransformFn) return _cachedTransformFn;
  try {
    _cachedTransformFn = new Function('total', 'playerIndex', 'round', 'return ' + expr);
    _cachedTransformExpr = expr;
    return _cachedTransformFn;
  } catch(e){ return null; }
}

function getWinFn(expr){
  if(!expr) return null;
  if(expr === _cachedWinExpr && _cachedWinFn) return _cachedWinFn;
  try {
    _cachedWinFn = new Function('total', 'playerIndex', 'round', 'return ' + expr);
    _cachedWinExpr = expr;
    return _cachedWinFn;
  } catch(e){ return null; }
}

function totals(){
  var players = getPlayers();
  var rounds = getRounds();
  var tfn = getTransformFn(yMeta.get('scoreTransform') || '');
  var t = players.map(function(){ return 0; });
  rounds.forEach(function(r, ri){
    r.forEach(function(v,i){ t[i] += (v||0); });
    if(tfn){
      for(var i = 0; i < t.length; i++){
        try { t[i] = tfn(t[i], i, ri); } catch(e){}
      }
    }
  });
  return t;
}

// ── Per-column widths from distinguishing prefixes (diagonal) ──
var _cosAngle = Math.cos(55 * Math.PI / 180);
var _chPx = null;
function chToPx(){
  if(_chPx) return _chPx;
  var el = document.createElement('span');
  el.style.cssText = 'font:700 .8rem monospace;position:absolute;visibility:hidden';
  el.textContent = '0';
  document.body.appendChild(el);
  _chPx = el.offsetWidth;
  document.body.removeChild(el);
  return _chPx;
}
function colWidths(names){
  if(names.length === 0) return [];
  // For each name, find the shortest prefix that distinguishes it
  var lens = names.map(function(n, i){
    var need = 1;
    for(var j = 0; j < names.length; j++){
      if(j === i) continue;
      var shared = 0;
      var a = n.toLowerCase(), b = names[j].toLowerCase();
      while(shared < a.length && shared < b.length && a[shared] === b[shared]) shared++;
      if(shared + 1 > need) need = shared + 1;
    }
    return Math.min(need, n.length);
  });
  var ch = chToPx();
  return names.map(function(n, i){
    return Math.max(20, Math.ceil(lens[i] * ch * _cosAngle));
  });
}

Loading and deleting saved games

Loading a saved game restores players and rounds from the snapshot and removes the entry from the archive — the game is now the active session, not a saved one. When you later save or start a new game, a fresh entry is created, so there is never a stale duplicate. Deleting a game removes it from the archive and refreshes the list.

function loadGame(idx){
  var g = getSavedGames()[idx];
  if(!g) return;
  var hasScores = yRounds.length > 0;
  if(hasScores){
    if(!confirm('Save current game before loading?')) return;
    saveGame();
  }
  yGames.delete(idx, 1);
  yPlayers.delete(0, yPlayers.length);
  yRounds.delete(0, yRounds.length);
  (g.players||[]).forEach(function(p){
    var pm = new Y.Map();
    pm.set('name', p.name);
    yPlayers.push([pm]);
  });
  // Backward compat: convert old tally format (finalScore/history) to rounds
  if(g.rounds){
    g.rounds.forEach(function(r){ yRounds.push([r]); });
  } else if(g.players && g.players[0] && g.players[0].history){
    var histories = g.players.map(function(p){
      var h = typeof p.history === 'string' ? JSON.parse(p.history) : (p.history || []);
      return h;
    });
    var maxLen = Math.max.apply(null, histories.map(function(h){ return h.length; }));
    for(var ri=0;ri<maxLen;ri++){
      var row = histories.map(function(h){
        var cur = ri < h.length ? h[ri] : (h.length ? h[h.length-1] : 0);
        var prev = ri > 0 && ri-1 < h.length ? h[ri-1] : 0;
        return cur - prev;
      });
      yRounds.push([row]);
    }
  }
  yMeta.set('gameName', g.gameName || '');
  yMeta.set('lowWins', !!g.lowWins);
  yMeta.set('scoreTransform', g.scoreTransform || '');
  yMeta.set('winCondition', g.winCondition || '');
  _cachedTransformFn = null;
  _cachedWinFn = null;
  Alpine.store('app').showGames = false;
  render();
}

function deleteGame(idx){
  if(confirm('Delete this saved game?')){
    yGames.delete(idx, 1);
    openLoadModal();
  }
}

Keeping the screen awake

Board game sessions can last hours. The Screen Wake Lock API keeps the display on so you don’t have to unlock your phone every time you want to record a score. The lock is re-requested when the tab regains visibility (the browser releases it when backgrounded).

// ── Wake Lock ──
var wakeLock = null;
async function requestWakeLock(){
  try{ if('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); }catch(e){}
}
document.addEventListener('visibilitychange', function(){
  if(document.visibilityState === 'visible') requestWakeLock();
});
requestWakeLock();

Installing as a PWA

The install button appears in the header when the browser signals the app is installable (beforeinstallprompt event). It’s hidden when already running in standalone mode. The early capture script runs in <head> so the event is never missed.

<script>
// Capture beforeinstallprompt early so it is not missed.
window.__pwaInstallEvent = null;
window.addEventListener('beforeinstallprompt', function(e) {
  e.preventDefault();
  window.__pwaInstallEvent = e;
  var btn = document.getElementById('btnInstall');
  if (btn) btn.style.display = '';
});
window.addEventListener('appinstalled', function() {
  window.__pwaInstallEvent = null;
  var btn = document.getElementById('btnInstall');
  if (btn) btn.style.display = 'none';
});
</script>

// ── PWA Install (late wiring) ──
if (window.__pwaInstallEvent) {
  $('btnInstall').style.display = '';
}
function installApp() {
  if (!window.__pwaInstallEvent) return;
  window.__pwaInstallEvent.prompt();
  window.__pwaInstallEvent.userChoice.then(function() {
    window.__pwaInstallEvent = null;
    $('btnInstall').style.display = 'none';
  });
}
if (window.matchMedia('(display-mode: standalone)').matches) {
  $('btnInstall').style.display = 'none';
}

Window API

Exposes render as a window global so the Playwright test suite can call it via page.evaluate().

// ── Window compat wrappers ──
window.renderFromYjs = render;

Alpine store

Alpine manages UI state — which modal is open, numpad visibility — while Yjs remains the source of truth for game data. The store exposes the functions defined in the feature blocks above so that @click directives in the HTML can call them.

// ── Alpine store ──
Alpine.store('app', {
  // Modal visibility
  showPick: false,
  showAddPlayer: false,
  showScoreboard: false,
  showGames: false,
  showNumpad: false,
  pickSelected: {},
  showReorder: false,
  reorderList: [],
  pickOrder: [],
});

Alpine.store('game', {
  players: [],
  rounds: [],
  entries: [],
  hasPlayers: false,
  gameName: '',
  lowWins: false,
  scoreTransform: '',
  winCondition: '',
  roster: [],
  savedGames: [],
  colWidths: [],
  get liveTotals(){
    var self = this;
    var tfn = getTransformFn(self.scoreTransform);
    var t = self.players.map(function(){ return 0; });
    self.rounds.forEach(function(r, ri){
      r.forEach(function(v, i){ if(i < t.length) t[i] += (v || 0); });
      if(tfn){
        for(var i = 0; i < t.length; i++){
          try { t[i] = tfn(t[i], i, ri); } catch(e){}
        }
      }
    });
    self.entries.forEach(function(v, i){
      if(i < t.length) t[i] += (v || 0);
    });
    if(tfn){
      for(var i = 0; i < t.length; i++){
        try { t[i] = tfn(t[i], i, self.rounds.length); } catch(e){}
      }
    }
    return t;
  },
  get liveMaxTotal(){
    var t = this.liveTotals;
    if(!t.length) return 0;
    return this.lowWins ? Math.min.apply(null, t) : Math.max.apply(null, t);
  },
  get liveWinners(){
    var self = this;
    var wfn = getWinFn(self.winCondition);
    if(!wfn) return {};
    var t = self.liveTotals;
    var winners = {};
    for(var i = 0; i < t.length; i++){
      try { if(wfn(t[i], i, self.rounds.length)) winners[i] = true; } catch(e){}
    }
    return winners;
  },
  get scoreboardEntries(){
    var self = this;
    var entries = self.players.map(function(p, i){ return {name: p.name, score: self.liveTotals[i]||0}; });
    var lw = self.lowWins;
    entries.sort(function(a, b){ return lw ? a.score - b.score : b.score - a.score; });
    var rank = 0;
    for(var i = 0; i < entries.length; i++){
      if(i > 0 && entries[i].score !== entries[i-1].score) rank++;
      entries[i].rank = rank;
    }
    return entries;
  },
  get availablePlayers(){
    var current = {};
    this.players.forEach(function(p){ current[p.name] = true; });
    return this.roster.filter(function(p){ return !current[p.name]; });
  },
});

function syncFromYjs(){
  var g = Alpine.store('game');
  var players = getPlayers();
  g.players = players.map(function(p, i){
    return { name: p.name, color: COLORS[i % 8] };
  });
  g.colWidths = colWidths(players.map(function(p){ return p.name; }));
  g.rounds = getRounds();
  g.hasPlayers = players.length > 0;
  g.gameName = yMeta.get('gameName') || '';
  g.lowWins = !!yMeta.get('lowWins');
  g.scoreTransform = yMeta.get('scoreTransform') || '';
  g.winCondition = yMeta.get('winCondition') || '';
  if(document.activeElement !== $('gameName')) $('gameName').value = g.gameName;
  // Resize entries to match player count, preserving values
  while(g.entries.length < players.length) g.entries.push('');
  while(g.entries.length > players.length) g.entries.pop();
  g.roster = getRoster().map(function(r){ return { name: r.get ? r.get('name') : r.name }; });
  var games = getSavedGames();
  g.savedGames = games.slice().reverse().map(function(gm, ri){
    var idx = games.length - 1 - ri;
    var d = new Date(gm.date);
    var dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
    var pStr = (gm.players||[]).map(function(p){ return esc(p.name)+': '+(p.score!==undefined?p.score:(p.finalScore||0)); }).join(', ');
    var nRounds = (gm.rounds||[]).length;
    return { gameName: gm.gameName||'Untitled', _dateStr: dateStr, _summary: pStr+' ('+nRounds+' rounds)', _idx: idx, _raw: gm };
  });
}
var render = syncFromYjs;

// Expose functions to Alpine's scope so @click directives work
window.openPickModal = openPickModal;
window.openAddPlayerModal = openAddPlayerModal;
window.addToRoster = addToRoster;
window.deleteFromRoster = deleteFromRoster;
window.togglePick = togglePick;
window.startGame = startGame;
window.updatePickStartEnabled = updatePickStartEnabled;
window.addPlayerFromInput = addPlayerFromInput;
window.addPlayerFromChip = addPlayerFromChip;
window.openScoreboard = openScoreboard;
window.toggleLowWins = toggleLowWins;
window.setScoreTransform = setScoreTransform;
window.setWinCondition = setWinCondition;
window.showWinToast = showWinToast;
window.applyPreset = applyPreset;
window.applyPickPreset = applyPickPreset;
window.openLoadModal = openLoadModal;
window.newGame = newGame;
window.exportCSV = exportCSV;
window.deleteEditRound = deleteEditRound;
window.installApp = installApp;
window.setGameName = function(v){ yMeta.set('gameName', v); };
window.updatePlayerName = function(i, v){ yPlayers.get(i).set('name', v); };
window.removePlayer = removePlayer;
window.openEdit = openEdit;
window.openNumpad = openNumpad;
window.openNumpadForEdit = openNumpadForEdit;
window.addRound = addRound;
window.loadGame = loadGame;
window.deleteGame = deleteGame;
window.openReorderModal = openReorderModal;
window.confirmReorder = confirmReorder;
window.syncFromYjs = syncFromYjs;

Alpine.start();

Visual foundation

Dark theme with CSS custom properties. The body is a flex column: sticky header, scrollable table area, and the numpad fixed at the bottom when open.

*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#1a1a2e;--surface:#16213e;--card:#0f3460;
  --accent:#e94560;--accent2:#533483;--text:#eee;--muted:#888;
  --green:#4ecca3;--red:#e94560;--radius:12px;
}
html,body{height:100%}
body{
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
  background:var(--bg);color:var(--text);
  -webkit-tap-highlight-color:transparent;
  display:flex;flex-direction:column;
}

Shared modal overlay and content styling. The popIn animation gives a subtle scale entrance.

/* ── Modals ── */
.modal-overlay{
  position:fixed;inset:0;background:rgba(0,0,0,.6);
  display:none;place-items:center;z-index:100;
  backdrop-filter:blur(4px);
}
.modal-overlay.open{display:grid}
.modal{
  background:var(--surface);border-radius:20px;
  padding:28px;width:min(400px,92vw);
  animation:popIn .2s ease-out;
  max-height:85vh;overflow-y:auto;
}
@keyframes popIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
.modal h2{margin-bottom:16px;font-size:1.2rem}
.modal-row{
  display:flex;justify-content:space-between;align-items:center;
  padding:10px 14px;background:rgba(255,255,255,.05);border-radius:12px;
  margin-bottom:6px;
}
.modal-row .rank{font-weight:800;font-size:1.1rem;margin-right:12px}
.modal-row .rank.gold{color:#ffd700}
.modal-row .rank.silver{color:#c0c0c0}
.modal-row .rank.bronze{color:#cd7f32}
.modal-name{font-weight:600}
.modal-score{font-weight:800;font-size:1.2rem}
.modal-close{
  margin-top:16px;width:100%;padding:14px;border-radius:12px;
  background:var(--accent);border:none;color:#fff;
  font-size:.95rem;font-weight:700;cursor:pointer;font-family:inherit;
}
.modal-close:disabled{opacity:.35;cursor:not-allowed}
.pick-chips{display:flex;flex-wrap:wrap;gap:8px}
.pick-chip{
  padding:10px 18px;border-radius:20px;
  background:rgba(255,255,255,.08);border:1.5px solid rgba(255,255,255,.15);
  color:var(--text);font-size:.9rem;font-weight:600;cursor:pointer;
  font-family:inherit;transition:all .15s;
}
.pick-chip.selected{
  background:var(--accent);border-color:var(--accent);color:#fff;
}
.pick-chip-wrap{position:relative;display:inline-block}
.pick-chip-del{
  position:absolute;top:-6px;right:-6px;
  width:18px;height:18px;border-radius:50%;
  background:var(--red,#e94560);border:none;color:#fff;
  font-size:.65rem;line-height:18px;text-align:center;
  cursor:pointer;padding:0;opacity:.7;transition:opacity .15s;
}
.pick-chip-del:hover{opacity:1}

Persistence across reload

The debug stub persists to localStorage. A page reload must restore players and scores exactly as they were — this is the closest thing to a crash-recovery test we can do in the browser.

Verifying that scores survive a page reload

After adding a player and entering a round, a full page reload should bring back the same players and totals. This exercises the localStorage round-trip of the debug Yjs stub.

def test_persistence_across_reload(page):
    """Data survives a page reload."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    enter_score_via_numpad(page, 0, "33")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    names = get_player_names(page)
    assert "Alice" in names, f"Alice not found after reload: {names}"
    t = get_totals(page)
    assert t == [33], f"Expected [33] after reload, got {t}"
    print("  PASS: persistence across reload")

Add Alice and enter a round of 33 points.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
enter_score_via_numpad(page, 0, "33")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Force a full page reload.

page.reload()
page.wait_for_selector("#btnAddPlayer")

Alice and her score should still be there.

names = get_player_names(page)
assert "Alice" in names, f"Alice not found after reload: {names}"
t = get_totals(page)
assert t == [33], f"Expected [33] after reload, got {t}"

Starting a new game after a reload

The roster is persisted as structured objects. After a page reload the stub deserialises them — the pick modal must still be able to read player names and start a new game. This catches regressions where the deserialised format breaks .name access.

Verifying that a new game can start after reload

Set up a game with two players, reload, then start a fresh game from the pick modal. The modal should close and the new game should have both players.

def test_new_game_after_reload(page):
    """Starting a new game works after a page reload."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    open_new_game(page)
    page.fill("#pickGameName", "AfterReload")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden", timeout=3000)
    assert page.input_value("#gameName") == "AfterReload"
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    print("  PASS: new game after reload")

Add two players and start a game so the roster is populated in storage.

clear_state(page)
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Alice")
page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")

Reload the page so the stub deserialises the roster from localStorage, then open the pick modal, name the game, and start.

page.reload()
page.wait_for_selector("#btnAddPlayer")
open_new_game(page)
page.fill("#pickGameName", "AfterReload")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden", timeout=3000)

The game should have started with both players.

assert page.input_value("#gameName") == "AfterReload"
names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"

Game name persists

The game name is stored in yMeta. This is tested separately from the score data because it follows a different code path (the #gameName input dispatches its own input event into the Yjs map).

Checking that the game name survives a reload

Setting the game name via the header input and reloading the page must restore the same value, confirming that the yMeta map is persisted and read back on startup.

def test_game_name_persists(page):
    """Game name field persists across reload."""
    clear_state(page)
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Alice")
    page.evaluate("document.getElementById('gameName').value = 'MyGame';"
                  "document.getElementById('gameName').dispatchEvent(new Event('input'));")
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    assert page.input_value("#gameName") == "MyGame"
    print("  PASS: game name persists")

Set the game name programmatically and fire the input event so Yjs picks it up.

page.evaluate("document.getElementById('gameName').value = 'MyGame';"
              "document.getElementById('gameName').dispatchEvent(new Event('input'));")

After a reload the field should still read “MyGame”.

page.reload()
page.wait_for_selector("#btnAddPlayer")
assert page.input_value("#gameName") == "MyGame"

Notes linking here