Konubinix' opinionated web of thoughts

Score Counter Tally

Fleeting

open

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>
    <thead>
      <tr id="headerRow">
        <th>#</th>
        <template x-for="(p, i) in $store.game.players" :key="i">
          <th :style="'border-bottom:3px solid ' + p.color">
            <input class="player-name-input" :value="p.name" :data-pidx="i"
              x-bind:placeholder="'Player ' + (i+1)" spellcheck="false"
              @input="updatePlayerName(i, $el.value)">
            <button class="remove-player" :data-pidx="i" title="Remove"
              @click="removePlayer(i)">&#x2715; remove</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="number" readonly class="entry-input"
              :data-pidx="i" :id="'entry_' + i" placeholder="0"
              :data-pname="p.name"
              @click="openNumpad($el)">
          </td>
        </template>
      </tr>
    </tbody>
    <tfoot>
      <tr id="totalRow">
        <td>&#x03a3;</td>
        <template x-for="(t, i) in $store.game.totals" :key="'t'+i">
          <td style="font-weight:800;font-size:1rem"
            :class="{ 'winner': $store.game.players.length > 1 && t === $store.game.maxTotal && t !== 0 }"
            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)">
  <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 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}

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.

<!-- 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>
    <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. 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++) a.push(yRoster.get(i));
  return a;
}

function deleteFromRoster(idx) {
  var name = yRoster.get(idx).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++) {
    if (yRoster.get(i).name === name) { alert(name + ' already exists'); return; }
  }
  yRoster.push([{ name: name }]);
  $('rosterInput').value = '';
  syncFromYjs();
  $('rosterInput').focus();
}

// ── Player picker ──
function togglePick(name) {
  var sel = Alpine.store('app').pickSelected;
  if (sel[name]) { delete sel[name]; } else { sel[name] = true; }
  Alpine.store('app').pickSelected = Object.assign({}, sel);
}

function openPickModal() {
  var sel = {};
  for (var i = 0; i < yPlayers.length; i++) {
    sel[yPlayers.get(i).get('name')] = true;
  }
  Alpine.store('app').pickSelected = sel;
  $('pickGameName').value = yMeta.get('gameName') || '';
  $('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 sel = Alpine.store('app').pickSelected;
  var names = [];
  getRoster().forEach(function(p) {
    if (sel[p.name]) names.push(p.name);
  });
  if (names.length === 0) { alert('Select at least one player'); return; }
  yMeta.set('gameName', gn);
  $('gameName').value = gn;
  yPlayers.delete(0, yPlayers.length);
  yRounds.delete(0, yRounds.length);
  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 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 and the game name should still be there.

assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()
assert page.input_value("#pickGameName") == "BoardNight", \
    f"Game name lost after adding roster player: {page.input_value('#pickGameName')}"

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++) {
    if (yRoster.get(j).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.

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:separate;border-spacing:0;
  width:100%;min-width:fit-content;
}
th,td{
  padding:7px 10px;text-align:center;
  border-bottom:1px solid rgba(255,255,255,.06);
  min-width:90px;
}
th:first-child,td:first-child{
  position:sticky;left:0;z-index:2;
  background:var(--bg);min-width:42px;width:42px;
  color:var(--muted);font-size:.75rem;
}
thead th{
  position:sticky;top:0;z-index:3;
  background:var(--surface);font-size:.8rem;font-weight:700;
}
thead th:first-child{z-index:4;background:var(--surface)}
.player-name-input{
  background:none;border:none;color:var(--text);
  font-family:inherit;font-size:.8rem;font-weight:700;
  text-align:center;width:100%;outline:none;padding:2px 0;
}
.player-name-input::placeholder{color:var(--muted)}
.player-name-input:focus{border-bottom:1.5px solid var(--accent)}
.remove-player{
  font-size:.6rem;color:var(--muted);cursor:pointer;
  background:none;border:none;display:block;margin:2px auto 0;
}
.remove-player:active{color:var(--red)}
.score-cell{
  cursor:pointer;font-variant-numeric:tabular-nums;
  font-weight:500;font-size:.85rem;transition:background .1s;
  border-radius:4px;
}
.score-cell:hover{background:rgba(255,255,255,.06)}
.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}

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).

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>
  <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="clear">C</button>
    <button class="np-btn" data-key="0">0</button>
    <button class="np-btn" colspan="2" data-key="00">00</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;

function npSync() {
  var val = npNeg && npRaw !== '' && npRaw !== '0' ? '-' + npRaw : npRaw;
  $('npValue').textContent = val || '0';
  if (npTarget) npTarget.value = val || '';
}

function openNumpad(input) {
  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');
  Alpine.store('app').showNumpad = true;
}

function closeNumpad() {
  Alpine.store('app').showNumpad = false;
  if (npTarget) npTarget.classList.remove('active-pad');
  npTarget = null;
}

$('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) {
    // Quick increment
    var cur = parseInt(npTarget && npTarget.value) || 0;
    cur += parseInt(delta);
    npNeg = cur < 0;
    npRaw = Math.abs(cur).toString();
    npSync();
    return;
  }

  if (key >= '0' && key <= '9') {
    npRaw += key;
    npSync();
  } else if (key === '00') {
    npRaw += '00';
    npSync();
  } else if (key === 'del') {
    npRaw = npRaw.slice(0, -1);
    npSync();
  } else if (key === 'clear') {
    npRaw = '';
    npNeg = false;
    npSync();
  } else if (key === 'sign') {
    npNeg = !npNeg;
    npSync();
  } else if (key === 'next') {
    if (!npTarget) return;
    var idx = parseInt(npTarget.dataset.pidx);
    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;
}
.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:.7rem;color:var(--muted);font-weight:600}
.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}

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}"]')

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")

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(){
  if(yPlayers.length === 0){ alert('Add players first!'); return; }
  var scores = [];
  var row = $('scoreBody').querySelector('.entry-row');
  if(!row) return;
  var inputs = row.querySelectorAll('.entry-input');
  var anyNonZero = false;
  inputs.forEach(function(inp){
    var v = parseInt(inp.value) || 0;
    scores.push(v);
    if(v !== 0) anyNonZero = true;
  });
  if(!anyNonZero && !confirm('All scores are 0. Add this round?')) return;
  closeNumpad();
  yRounds.push([scores]);
  render();
  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}"

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 a modal opens with the current value pre-filled. You can change it and save, or delete the entire round. The edit modal also supports Enter to save — no need to reach for the button.

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 Modal -->
<div class="modal-overlay" id="modalEdit" :class="{ 'open': $store.app.showEdit }" @click.self="$store.app.showEdit = false">
  <div class="modal">
    <h2 id="editTitle">Edit Score</h2>
    <input type="number" class="edit-input" id="editInput" inputmode="numeric" @keydown.enter="saveEdit()">
    <div class="edit-actions">
      <button class="ed-del" id="editDelete" @click="deleteEditRound()">Delete Round</button>
      <button class="ed-save" id="editSave" @click="saveEdit()">Save</button>
    </div>
    <button class="edit-cancel" id="editCancel" @click="$store.app.showEdit = false">Cancel</button>
  </div>
</div>

// ── Edit cell ──
var editRound = -1, editPlayer = -1;

function openEdit(ri, pi){
  editRound = ri;
  editPlayer = pi;
  var players = getPlayers();
  $('editTitle').textContent = players[pi].name + ' \u2014 Round ' + (ri+1);
  $('editInput').value = yRounds.get(ri)[pi];
  Alpine.store('app').showEdit = true;
  setTimeout(function(){ $('editInput').focus(); $('editInput').select(); }, 50);
}

function saveEdit(){
  var r = yRounds.get(editRound).slice();
  r[editPlayer] = parseInt($('editInput').value) || 0;
  yRounds.delete(editRound, 1);
  yRounds.insert(editRound, [r]);
  Alpine.store('app').showEdit = false;
  render();
}

function deleteEditRound(){
  if(!confirm('Delete round ' + (editRound+1) + '?')) return;
  yRounds.delete(editRound, 1);
  Alpine.store('app').showEdit = false;
  render();
}

/* ── Edit cell modal ── */
.edit-input{
  width:100%;background:rgba(255,255,255,.08);border:1.5px solid rgba(255,255,255,.15);
  border-radius:10px;color:var(--text);font-family:inherit;font-size:1.5rem;
  font-weight:700;text-align:center;padding:14px;outline:none;
  -moz-appearance:textfield;
}
.edit-input:focus{border-color:var(--accent)}
.edit-input::-webkit-inner-spin-button,
.edit-input::-webkit-outer-spin-button{-webkit-appearance:none}
.edit-actions{display:flex;gap:8px;margin-top:12px}
.edit-actions button{
  flex:1;padding:12px;border-radius:10px;border:none;
  font-size:.9rem;font-weight:700;cursor:pointer;font-family:inherit;color:#fff;
}
.edit-actions .ed-del{background:rgba(255,255,255,.08);color:var(--text)}
.edit-actions .ed-save{background:var(--accent)}
.edit-cancel{
  margin-top:10px;width:100%;padding:12px;border-radius:10px;
  background:rgba(255,255,255,.08);border:none;color:var(--text);
  font-size:.9rem;font-weight:600;cursor:pointer;font-family:inherit;
}

Correcting a score after the round is committed

Mistakes happen — tapping a committed score cell opens an edit modal where the value can be corrected. This test enters a round, edits the cell to a different number, 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("#modalEdit.open")
    page.fill("#editInput", "15")
    page.click("#editSave")
    page.wait_for_selector("#modalEdit", state="hidden")
    t = get_totals(page)
    assert t == [15], f"Expected [15], got {t}"
    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")

Click the committed cell, change the value to 15, and save.

page.click('td.score-cell[data-round="0"][data-player="0"]')
page.wait_for_selector("#modalEdit.open")
page.fill("#editInput", "15")
page.click("#editSave")
page.wait_for_selector("#modalEdit", state="hidden")

The total should now be 15 instead of 5.

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

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}"

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 from highest to lowest 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.

The scoreboard sorts players by total score descending and renders ranked rows with gold/silver/bronze styling for the top 3. 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: i===0, silver: i===1, bronze: i===2 }"
              x-text="'#' + (i+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>
    <button class="modal-close" id="closeScoreboard" @click="$store.app.showScoreboard = false">Close</button>
  </div>
</div>

// ── Scoreboard ──
function openScoreboard(){
  Alpine.store('app').showScoreboard = true;
}

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}"

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 restores players and rounds from the snapshot, with 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',
    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")

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>' 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.

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++) a.push(yGames.get(i));
  return a;
}

function totals(){
  var players = getPlayers();
  var rounds = getRounds();
  var t = players.map(function(){ return 0; });
  rounds.forEach(function(r){
    r.forEach(function(v,i){ t[i] += (v||0); });
  });
  return t;
}

Loading and deleting saved games

Loading a saved game restores players and rounds from the snapshot. 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();
  }
  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 || '');
  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,
  showEdit: false,
  showScoreboard: false,
  showGames: false,
  showNumpad: false,
  pickSelected: {},
});

Alpine.store('game', {
  players: [],
  rounds: [],
  totals: [],
  maxTotal: 0,
  entries: [],
  hasPlayers: false,
  gameName: '',
  roster: [],
  savedGames: [],
  get scoreboardEntries(){
    var self = this;
    var entries = self.players.map(function(p, i){ return {name: p.name, score: self.totals[i]||0}; });
    entries.sort(function(a, b){ return b.score - a.score; });
    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.rounds = getRounds();
  g.hasPlayers = players.length > 0;
  g.gameName = yMeta.get('gameName') || '';
  if(document.activeElement !== $('gameName')) $('gameName').value = g.gameName;
  var t = totals();
  g.totals = t;
  g.maxTotal = Math.max.apply(null, t.length ? t : [0]);
  // Resize entries to match player count, preserving values
  while(g.entries.length < players.length) g.entries.push(0);
  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.openLoadModal = openLoadModal;
window.newGame = newGame;
window.exportCSV = exportCSV;
window.saveEdit = saveEdit;
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.addRound = addRound;
window.loadGame = loadGame;
window.deleteGame = deleteGame;
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}"

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