Skip to content

Build a Working Scoreboard for a Small TV-Format Demonstration

Television

Build a Working Scoreboard for a Small TV-Format Demonstration

Somewhere between the pitch deck and the first rehearsal, a format team ends up doing this: one person reads the rules aloud, another types "A +1" into a notes app, and a third asks whether the last point went to the right player. The demonstration works — barely — but the score is just text, and the rule about who could earn that point lives entirely in someone's head.

The fix is smaller than it sounds. Build the scoreboard around an explicit round state: which phase the round is in, which decisions have been confirmed, and what is still pending. Each control requests a named change. The screen redraws from the state. Keep it that simple, and phase checks, correction and reset fall out almost for free.

That last sentence is the whole design, and also the whole caution. A working score display renders rule state somebody already agreed on. It must not quietly become the judge, and it must not swallow an invalid action as if it had counted.

Decide what the prototype is for

Pick the smallest round that still has the shape you need to show. Two players, three questions, one point each, three phases:

  • In play — the questions are being asked. Awards are proposed and confirmed here.
  • Review — the round is over, and recorded decisions can be corrected.
  • Final — the result is locked. The demonstration can be explained and reset.

Pending, confirmed and final are three different things. A point that has been proposed but not confirmed is not on the board. A confirmed decision is on the board. A final result is on the board and stays put.

Three things sit outside this prototype, and it helps to say so out loud rather than half-build them. This is not a broadcast graphics system. It is not two synchronized windows for an operator and a public display. And it does not decide the competition's rules or certify fairness — it shows what the operator's rules already produced. If the format needs an appeals process, that is a meeting, not a switch statement.

One more scope note: keep the whole round in memory. Refreshing the page, closing the tab or reloading during a demo clears the state and starts over. That is acceptable for a local demonstration, because resetting costs one click. Do not describe the demo as saving or restoring anything until storage has actually been added and checked.

Put decisions in state, and derive the totals

The state is a plain object — the kind MDN's Working with objects describes as named properties with values. Nothing here is exotic; the point is what the object refuses to contain.

const QUESTIONS = ['q1', 'q2', 'q3'];
const PLAYERS = ['A', 'B'];
const POINT = 1;

function freshRound() {
  return {
    phase: 'open',   // 'open' | 'review' | 'final'
    pending: null,   // { kind, questionId, player, value } — proposed, not counted
    decisions: {}    // questionId -> { player, value } — confirmed
  };
}

There is no scoreA field. Totals are computed from the confirmed decisions every time you need them:

function totals(round) {
  const t = Object.fromEntries(PLAYERS.map((p) => [p, 0]));
  for (const d of Object.values(round.decisions)) t[d.player] += d.value;
  return t;
}

This is the decision that prevents most of the trouble later. If you keep a separate editable total next to the decision record, you now have two sources of truth and one of them will eventually disagree with the other. Correction becomes dangerous, because "change Q3 from A to B" has to remember to subtract a point somewhere. Derive the totals and correction is just a replacement. The number on screen is never the score; it is a rendering of the score.

One control, one named transition

Every operator action arrives as a named request, and one function decides whether the current phase permits it. MDN's introduction to events covers the registration side — a click invokes a handler you registered. What the handler means is entirely yours to define. Keep that meaning out of the handler.

function apply(round, action, payload = {}) {
  const no = (reason) => ({ ok: false, reason, round });

  switch (action) {
    case 'propose':
      if (round.phase !== 'open') return no('Awards are proposed while the round is in play.');
      if (!QUESTIONS.includes(payload.questionId)) return no('That question is not in this round.');
      if (!PLAYERS.includes(payload.player)) return no('That player is not in this round.');
      if (round.decisions[payload.questionId]) return no('That question already has a decision.');
      return { ok: true, round: { ...round, pending: {
        kind: 'award', questionId: payload.questionId,
        player: payload.player, value: POINT } } };

    case 'confirm':
      if (!round.pending) return no('There is no pending change to confirm.');
      return { ok: true, round: {
        ...round,
        decisions: { ...round.decisions, [round.pending.questionId]: {
          player: round.pending.player, value: round.pending.value } },
        pending: null } };

    case 'beginReview':
      if (round.phase !== 'open') return no('Only a round in play moves into review.');
      return { ok: true,
        reason: round.pending
          ? 'Review started; the pending award was cleared without being counted.'
          : '',
        round: { ...round, phase: 'review', pending: null } };

    case 'correct':
      if (round.phase !== 'review') return no('Corrections belong to the review phase.');
      if (!round.decisions[payload.questionId]) return no('That question has no recorded decision.');
      if (!PLAYERS.includes(payload.player)) return no('That player is not in this round.');
      return { ok: true, round: { ...round, pending: {
        kind: 'correction', questionId: payload.questionId, player: payload.player,
        value: round.decisions[payload.questionId].value } } };

    case 'lock':
      if (round.phase !== 'review') return no('The result locks out of review.');
      if (round.pending) return no('Resolve the pending change before locking.');
      return { ok: true, round: { ...round, phase: 'final' } };

    case 'reset':
      return { ok: true, round: freshRound() };

    default:
      return no('Unknown action.');
  }
}

Two habits make this worth the length. First, every branch that refuses returns the same round untouched, so an invalid request cannot half-apply. Second, a result carries a reason whenever the operator needs to be told something — a refusal, or an accepted transition that dropped something instead of counting it — so the interface can say what happened instead of going silent.

Notice that confirm has no phase check of its own, and that is deliberate rather than an oversight. A pending change can only be created in play or in review, entering review clears any pending change, and lock refuses to run while one exists. Those three guards together make "pending change during the final phase" unreachable, so a fourth check would be decoration. If you change any of the three, this one stops being safe — worth a comment in the file.

The middle guard has a cost, and it is exactly the kind this design exists to say out loud. Moving into review discards an unconfirmed proposal rather than counting it, so the transition now returns a reason — "Review started; the pending award was cleared without being counted." — and the notice shows it. The alternative is defensible too: refuse beginReview while a proposal is pending, the way lock does. But there is no discard control in this prototype, so an operator who proposed the wrong award would be left with a committed point or a reset round. Reporting the clear keeps that escape hatch and still leaves a trace.

If the format ever awards more than one point, validate the value in propose alongside the player and question. Here it is a constant, so there is nothing to validate.

Wiring stays thin. The markup names the action; the handler forwards it.

let round = freshRound();

document.addEventListener('click', (event) => {
  const el = event.target.closest('[data-action]');
  if (!el) return;
  const result = apply(round, el.dataset.action, {
    questionId: el.dataset.question,
    player: el.dataset.player
  });
  round = result.round;
  render(round, result);
});

The click handler contains no arithmetic and no rule. It reports what the operator asked for and shows what the rules allowed. That is the line the thesis draws: the display renders rule state rather than becoming an improvised authority. If you find yourself typing if (phase === 'review') inside a click listener, the rule has escaped into the wrong place.

Correction is not a second award

Look at propose and correct side by side. They are the same shape, with the conditions reversed: propose requires that no decision exists for the question, correct requires that one does. Both set a pending record. confirm then does the same thing in both cases — it writes decisions[questionId]. When the decision already existed, writing over it is the correction.

That is why correcting Q3 from A to B produces A=1, B=2 rather than layering a second point on the old award. There is no "add" anywhere in the commit path. The replacement happens because the key is the same.

Pending edits stay visibly pending, which is the part operators appreciate. After correct(q3, 'B') and before confirm(), the recorded decision is still A. The totals still read A=2, B=1. The screen shows a proposed change that has not been counted. The operator gets a moment to look at it. In the earlier version of this kind of tool — the notes-app version — the number just changed, and nobody could tell whether it had been meant.

After lock, correct refuses. Changing that policy means adding an explicit reopen action, run by someone, visible in the interface, moving the phase back to review. A hidden exception that lets one late correction through after the result is announced is worse than no exception, because it makes the displayed final result a lie with no timestamp.

Render from state, and make refusals visible

Rendering reads the state and nothing else.

function render(round, result) {
  const t = totals(round);
  scoreEl.textContent = `A ${t.A} – ${t.B} B`;
  phaseEl.textContent = { open: 'In play', review: 'Review', final: 'Final result' }[round.phase];
  listEl.innerHTML = QUESTIONS.map((q) => row(round, q)).join('');
  noticeEl.textContent = result && result.reason ? result.reason : '';
}

function row(round, q) {
  const d = round.decisions[q];
  const p = round.pending && round.pending.questionId === q ? round.pending : null;
  if (p) return `<li>${q} — proposed ${p.player} (pending, not counted)</li>`;
  if (d) return `<li>${q} — ${d.player}, confirmed</li>`;
  return `<li>${q} — not decided</li>`;
}

Write the phase, pending and confirmed states as words. Color is a fine extra signal, but a demo shown on a projector, screenshotted into a deck, or seen by someone who cannot distinguish two shades is not served by color alone. The word "pending" survives all three.

The noticeEl line is the one that keeps the thesis honest, so it renders any result that carries a reason: a refusal, or an accepted transition that left something behind. A rejected action has to leave a trace on screen. If apply returns "That question already has a decision" and the interface does nothing, the operator believes the click landed and moves on, and the scoreboard has concealed an invalid action. An aria-live region on that notice is a reasonable addition so the message is announced rather than only shown; whether it behaves as intended still has to be checked with the assistive technology the team actually uses.

Rendering can also show a different control set per phase — proposal buttons during play, correction controls in review — but treat that as convenience, not enforcement. A button left in the DOM can still be activated. The phase check in apply is what actually holds.

Walk the round by hand

Here is a two-player, three-question round traced against the guards above. This is a hand-trace of the rules, not a test run; it is what the code should produce.

# Action Phase Decisions after Totals Outcome
1 reset() open (empty) 0–0 accepted
2 propose(q1, A) open q1 pending → A 0–0 accepted
3 confirm() open q1 → A 1–0 accepted
4 propose(q2, B) open q2 pending → B 1–0 accepted
5 confirm() open q1 → A, q2 → B 1–1 accepted
6 propose(q3, A) open q3 pending → A 1–1 accepted
7 confirm() open q1 → A, q2 → B, q3 → A 2–1 accepted
8 confirm() again open unchanged 2–1 rejected — nothing pending
9 propose(q3, A) again open unchanged 2–1 rejected — q3 already decided
10 beginReview() open unchanged, nothing pending to clear 2–1 accepted
11 correct(q3, B) review q3 still A, pending correction → B 2–1 accepted
12 lock() review unchanged 2–1 rejected — pending unresolved
13 confirm() review q1 → A, q2 → B, q3 → B 1–2 accepted
14 lock() review unchanged 1–2 accepted
15 correct(q1, B) final unchanged 1–2 rejected — round is locked
16 reset() final (empty) 0–0 accepted

Step 7 is the round the team agreed on. Step 11 is the operator's proposal, and step 11's totals column is the important one: the correction has not been counted yet. Step 13 is where A loses a point as B gains one. Row 8 answers a double-clicked confirm button; row 9 answers a second press of the same proposal. Row 12 catches the operator who tries to lock before resolving the change they just proposed. This particular round never carries a proposal into beginReview, so the one transition that drops something rather than refusing it is not exercised here — the checklist below covers it.

What still has to be checked

Row-by-row reasoning about a switch statement is not the same as a working demonstration. The transition logic can be exercised on its own — feed a round object and an action into apply, compare the result — and that is worth doing first, because it is fast and it isolates the rules from the page. But passing those fixtures would only show the rules behave. It would not show that the buttons are reachable, that the labels read correctly on the projector, or that the operator can find the reset control while talking.

So, before this goes in front of anyone:

  • Run each row of the table as a fixture and confirm the accept/reject outcome.
  • Confirm that a double-activation of confirm — by mouse or by keyboard — is rejected once, not twice counted.
  • Tab through the controls in the browser and version you actually intend to use, and check that the order matches the round.
  • Deliberately correct a question and then lock without confirming, and check that the warning on screen is legible and that the totals have not moved.
  • Propose an award, move into review without confirming it, and check that the notice names the discarded proposal while the totals stay put.
  • Press reset from the final screen and confirm the board comes back empty.

None of this has been executed here, and no browser or version has been recorded. Documentation and reasoning can get the design right; they cannot establish that a demo survives a room.

When it is finished, what you have is small: one page, two players, a round that goes in play, into review, and to a locked result. It will not tell you who won. It will not decide whether a late correction was fair. It will show, in words, exactly what the rules you agreed on have recorded so far — and it will say so out loud when an operator asks for something the rules do not allow.

Frequently asked questions

What is the core design rule for this scoreboard prototype?

Build it around an explicit round state: which phase the round is in, which decisions are confirmed, and what is pending. Each control requests a named change, and the screen redraws from state. The display renders rule state; it must not become the judge or swallow an invalid action as if it counted.

Why derive totals from confirmed decisions instead of storing a score field?

A separate editable total beside the decision record creates two sources of truth that can eventually disagree. Correction becomes dangerous because changing Q3 from A to B would have to remember to subtract a point somewhere. Deriving totals makes correction a replacement, and the number on screen is a rendering of the score, not the score itself.

How does correcting a decision avoid adding a second point?

propose and correct are the same shape with reversed conditions: propose requires no decision for the question, correct requires one. Both set a pending record, and confirm writes decisions[questionId]. When the decision already existed, writing over it is the correction. There is no add in the commit path, so the replacement happens because the key is the same.

What happens to an unconfirmed award when the round moves into review?

beginReview clears the pending award rather than counting it and returns the reason: Review started; the pending award was cleared without being counted. The notice shows that reason. A different policy would be to refuse beginReview while a proposal is pending, but this prototype has no discard control.

What still has to be checked before the demo is used?

Run each row of the hand-trace table as a fixture, confirm double-activation of confirm is rejected once, tab through controls in the actual browser and version, correct a question and then try to lock without confirming, propose an award then move into review without confirming and check the notice names the discarded proposal while totals stay put, and press reset from the final screen to confirm the board comes back empty. The passage states none of this has been executed there and no browser or version has been recorded.

More in Television Browse all articles