Skip to content

Keep a Format Demo’s Operator Controls Separate From the Audience Display

Television

Keep a Format Demo’s Operator Controls Separate From the Audience Display

You already have a working single-view prototype: a score, a phase, and the answers you have not shown yet, all in one page. The pitch needs two windows — the one you drive and the one the room watches. The cheap version of that is the same page opened twice, and it holds together right up until both copies believe they own the score.

The decision that keeps the whole thing small is this: exactly one window owns the state. The operator page commits changes. The display page requests and renders them. What travels between the two is a public snapshot — an object built fresh from the current state, containing only the fields the audience may see at this moment in the round. Each snapshot carries a session ID, an owner ID, an instance nonce and a revision, so the display can tell a current update from a stale one.

Everything else is your application’s job. Browser messaging between windows is transport. It does not replay what the display missed, remember anything across a reload, or keep anyone out of the channel. A channel name is a label on a mailbox, not a lock on a door.

What follows is a build specification, not a transcript. The two-window demo described here has not been implemented or run. The checkpoints at the end are what to record when it is, because the interesting failures only appear the first time you refresh mid-round in front of someone.

One writer, or a conflict resolver you did not budget for

Two windows that can both commit a score need an answer to a question the demo does not have: who wins when both press +1 in the same second. That means ordering rules, tie-breaks, a merge, and a story you can tell yourself at 6 p.m. on pitch day about why the projected score is 30 and your laptop says 20. Two writers also means two sources of truth, and a demo is precisely the moment you cannot stop to debug which one is lying.

One writer means one source of truth and a display that renders. Keep the score, the phase and the unrevealed answers in the operator page. Nothing else.

The second half of the split is the payload, and this is where people stop early. A public snapshot is not the operator’s state with things deleted. It is a separate object assembled field by field from the current state:

// Operator page — proposed shape.
const instanceId = crypto.randomUUID(); // fresh on every page load; identifies this tab, not this owner
let privateState = { score: 20, phase: 'question', answer: 'the lighthouse' };

function publicView(state) {
  const view = { score: state.score, phase: state.phase };
  if (state.phase === 'reveal') view.answer = state.answer; // only when revealable
  return view;
}

function commit(changes) {
  privateState = { ...privateState, ...changes };
  revision += 1;
  link.postMessage({
    type: 'snapshot',
    sessionId, ownerId, instanceId, revision,
    state: publicView(privateState)
  });
}

The direction of construction matters. A blocklist goes stale the moment you add a field to operator state, and it fails open — the new field ships to the projector by default. An allowlist goes stale only if you forget to add something, and when it does, it fails closed. Since the display is the surface an unrehearsed audience can study for as long as it likes, fail closed.

Note what publicView does with the phase. During the question phase there is no answer key in the outgoing object at all — not blanked, not obfuscated, absent. At the reveal, answer appears, and the same snapshot mechanism carries it. The audience-visible shape of the round changes because the round changed, not because a different code path fired.

Hiding controls with CSS is not this separation. display: none leaves the answer text in the DOM, one keystroke away in any inspector, and one careless text-select away on a shared screen. Nor is it enough to leave the operator’s controls off the projected page — check what the display page loads. An answer key in a JSON file, an image of the answer, a font icon with the answer in its accessible name, all travel with the bundle and are visible in a network panel regardless of what you render.

Finally, decide whether the display is allowed to ask for anything. If the presenter wants a reveal button on the touchscreen, that button posts a request; the owner validates it and commits. The display still never writes state. A request is not a commit, and the distinction is the whole design.

A public update needs a session, an owner and a revision

Both views have to be served from compatible contexts. MDN’s Broadcast Channel API page (last modified 21 February 2025) documents the mechanism this proposal leans on: eligible browsing contexts exchange messages through a named channel, subject to same-origin and storage-partition conditions, and the meaning of those messages is left to the application. That last part is not a footnote — it is the entire remainder of this article.

Same-origin here means “convenient boundary,” not “secure boundary.” Any page on that origin can post to the channel, and any page on that origin can also just fetch the operator page. Storage partitioning cuts the other way too: a display page embedded in a frame on a different top-level site may not be in the same partition as your operator tab. Launch the views the way you intend to launch them during the pitch and verify pairing there, rather than assuming a same-origin URL is enough.

Pairing should be explicit. The operator shows or states a session ID; the display is given it, along with the expected owner ID, before it accepts anything. Out of band, once, at the start of the demo. The operator also mints a fresh instance nonce every time its page loads — a value that identifies this tab rather than this owner, and the only thing that lets the display notice a second tab claiming the same session. Then every snapshot is checked:

// Display page — proposed shape.
let paired = { sessionId: 'A', ownerId: 'op-1' };
let instance = null;   // nonce of the operator tab currently being believed
let baseline = null;   // revision of the last accepted snapshot for this session

function accept(m) {
  if (!m || m.type !== 'snapshot') return 'ignore';
  if (m.sessionId !== paired.sessionId) return 'ignore';
  if (m.ownerId !== paired.ownerId) return 'ignore';
  if (typeof m.instanceId !== 'string') return 'ignore';
  if (typeof m.revision !== 'number') return 'ignore';
  if (instance === null) instance = m.instanceId;
  else if (m.instanceId !== instance) return 'conflict';
  if (baseline !== null && m.revision <= baseline) return 'ignore';
  baseline = m.revision;
  return 'render';
}

The function has three outcomes. ignore covers a message that is not for this display: wrong type, wrong session, wrong owner, or a revision no newer than what is already on screen. render is a snapshot worth drawing. conflict is a second operator instance claiming the paired session and owner. The revisions in a conflicting snapshot may be perfectly plausible — the disagreement is about which tab is speaking, and no comparison of numbers will settle it, so it goes to the hold rather than the render path.

Work through the running example. Session A, owner op-1, revision 4, score 20, question phase. The public object is { score: 20, phase: 'question' } and no answer text. Now inject revision 3 — an earlier snapshot, delayed in delivery. 3 <= 4, ignored, score stays 20. Re-send revision 4, a duplicate. Also ignored, nothing repaints.

Those two rejections look identical and are not. The first is ordering: an old message arrived late. The second is idempotence: the same message arrived twice. One comparison covers both, which is convenient, but it is worth knowing which failure you are defending against, because when the rule misfires you will want to know which one you actually hit.

baseline === null is doing real work. Until the first valid snapshot arrives, there is nothing to compare against, so the first one is the baseline. That is the recovery rule from the next section, wearing a different hat: order only protects you after you have a starting point.

It also means the baseline is memoryless. A display that has just loaded, or has just re-paired, has no record of how far the session had already progressed, so it will accept a lower revision of that session if that is what arrives first, and show it until a higher one arrives. The pull in the next section makes that unlikely — the display asks for current state rather than waiting for the next commit — but nothing in the comparison rule prevents it.

And notice that the fifth commit in the example does not change the score. The operator moves the phase from question to reveal; the score stays 20; revision 5 goes out carrying { score: 20, phase: 'reveal', answer: 'the lighthouse' }, the first time the answer field appears in a public object at all. Revisions track committed state changes, not numeric ones. If you tie revisions to score mutations only, disclosure changes silently stop propagating, which is the one update the audience is waiting for.

Recover the display by asking, not remembering

The tempting design sends only increments: +1, reveal, next. It is compact, and it makes recovery impossible. If the display drops one message — a refresh, a sleeping tab, a momentary partition — every subsequent delta is applied to a state that is wrong by exactly that amount, and your recovery plan becomes “hope nothing was missed.” Replaying a log properly means ordering guarantees, gap detection and a rebroadcast protocol. You have just rebuilt a database to avoid sending a bigger object.

Send the snapshot instead, and make recovery a pull. When the display loads or refreshes, it posts a request for the current snapshot in its paired session; the owner answers with its present public state, at whatever revision that happens to be. No replay, no catch-up arithmetic. In the example, the display refreshes mid-round, asks, receives revision 4 with score 20, renders it, and then resumes accepting only newer revisions.

The owner’s side of that is trivial and worth stating: answer snapshot requests with current state regardless of revision history. It does not need to know what the display has seen. It needs no per-display bookkeeping at all, which is what keeps the two-view demo small enough to trust.

There is a cost, and it lands on the projector. Between load and the first valid snapshot there is a moment with no data, and that moment is on screen. Render a waiting state, not a zero. A zero reads as a real score to anyone who walked in a second ago, and if your demo has a round reset that lands at zero, the audience cannot tell a healthy restart from a failed sync. Absence of data is not data, and on a public surface the difference is a score that means something.

Heartbeats are not revisions

The obvious liveness shortcut is to bump the revision on a timer. Do not. If a timer advances the revision, your stale-update rule is competing with noise: every quiet second manufactures a change, the display repaints constantly, and the revision number stops meaning “a committed change happened.” Worse, it conflates two different questions — “is the state newer?” and “is the owner still there?” — into one field, so you can no longer answer either cleanly.

Keep them separate. The owner sends a liveness message on an interval, with the session, owner and instance identities and no revision:

link.postMessage({ type: 'alive', sessionId, ownerId, instanceId });

The display tracks the last valid heartbeat from its paired owner and the instance it currently believes. For the demo, pick explicit test settings — a heartbeat every second and a hold after three seconds with no valid paired-owner response, for instance. Those are numbers chosen for a local pitch prototype, not reliability guarantees, and they should be written down as such rather than inherited from a production system that had different problems.

“Valid paired-owner response” is the load-bearing phrase. A busy channel is not liveness. If another tab on the same origin is chattering, or a stale operator from an earlier run is still alive, the display receives plenty of messages and none of them mean anything. Only a heartbeat matching the paired session, owner and active instance resets the timer. A heartbeat that matches session and owner but carries a different instance nonce is the competing-instance conflict, and it forces the same hold a conflicting snapshot would. When the timer expires, show a hold — a state that names the condition and looks like a state, not like a live score with a small badge in the corner. A reassuring “connected” indicator that stays lit through a real disconnect is worse than no indicator, because it teaches the room to trust the number.

A restart is a new session, not a bigger number

Reload the operator page and its in-memory state is gone. That is not a bug to paper over; it is the honest starting condition. The reloaded operator is a new session — call it B — with a fresh revision counter starting from wherever your code starts it. Session A’s revision 5 and session B’s revision 1 are not comparable numbers. Nothing in the protocol makes them comparable, and no arithmetic on them produces a meaningful answer.

This is where a display that only knows the revision rule fails loudly. If it accepts B/1 as the newest update, it displays whatever the fresh session happens to hold — commonly a zero, arriving on the projector as if the round restarted. If it tries to be clever and merge, the audience gets a score neither window ever committed. So the rule is the opposite of clever: a snapshot from an unrecognized session goes to reconciliation, and a human decides.

Concretely, restart the operator as session B during rehearsal and watch what the display does. The correct behavior is a hold, plus a deliberate re-pairing step in which you confirm the current score and give the display the new session ID. Re-pairing resets the baseline and the instance nonce to null, which is exactly right: session B’s first valid snapshot becomes the new baseline for session B, the tab that sends it becomes the instance the display believes, and stale comparisons resume from there. Nothing carries over, because nothing should.

A competing owner claim for the paired session reaches the same hold, but only because the instance nonce makes it visible. Two operator tabs configured with the same session and owner IDs produce snapshots and heartbeats that agree on every field the display was already checking — same session, same owner, revisions that both look plausible. Without a per-tab nonce, the display would accept both and the projected score would flip between two states that each look correct. With it, the second tab’s first message disagrees on instanceId, the display stops believing either one, and the hold appears. For a pitch demo, this is a feature. The conflict you cannot resolve automatically is exactly the conflict you want surfaced rather than guessed at.

If you want an operator restart to resume the round instead of starting a new session, that is persistence, and it is a separate design with its own trust story — where the state lives, what happens if the store is empty, whether a stale store can resurrect last week’s round. Half-doing it is worse than not doing it, because a restored session that only sometimes restores is a failure mode nobody can rehearse.

Rehearse the failures the room can actually produce

Five tests cover the ground this design is defending:

Refresh the display mid-round and confirm the first thing it renders is the snapshot, not a zero. Inject a duplicate and an out-of-order message and confirm neither changes the visible state. Open a second operator tab with the same session and owner IDs and confirm the display holds instead of accepting whichever revision arrives next. Restart the operator and confirm the display holds and requires a deliberate reconciliation and re-pair instead of silently adopting the new session. Stop communication — close the operator tab outright — and confirm the hold appears after your declared timeout, then confirm the manual fallback you announced actually works.

Alongside those, inspect the projected surface rather than looking at it. Load the display page and open view source, the network panel and the scripts it pulls. Search all of it for the answer text, and for the answer in any other form your assets might carry it. Five of these checks find a page that looks right; only the inspection finds the page that looks right and leaks.

Record the browser, the way the two pages were served, and what you observed for each. When something holds that should not, the recorded conditions are what let you tell a real defect from a machine that went to sleep.

And to be plain about the evidence: none of these tests were run for this article. The behavior described in the walkthrough is what the protocol requires, spelled out so it can be built and checked. The failing build has a recognizable signature — a refresh that lands on zero, or a display page whose hidden data carries the answers — and recognizing it is not the same as having produced it.

What the demo hands over

The deliverable is a local two-window pitch package: the operator page, the display page, a pairing step, a hold state, and an operator recovery note short enough to be read while someone is talking. The note is not documentation. It is a card, and it might say:

Session A, display paired. If the display shows HOLD: say the score out loud and keep the round going; re-pair at the next break. Do not reload the operator page mid-round — that starts session B, and the display will hold until you confirm the score.

That is the honest version of the promise this whole design makes: when the two views disagree, the room finds out, and a person resolves it. It is a local prototype for a pitch, not broadcast infrastructure, and it should not be described as secure just because the two pages share an origin. The recovery note is what makes the hold survivable in the room — and the hold is the reason you can trust the number on screen for the ninety seconds that matter.

Frequently asked questions

Why should only one window own the demo's state?

Two windows that can both commit a score need ordering rules, tie-breaks, a merge, and a way to explain a disagreement on pitch day. One writer means one source of truth and a display that renders. The operator page commits changes; the display page requests and renders them.

What belongs in a public snapshot?

A snapshot is assembled field by field from current state, not the operator state with fields deleted. It carries a session ID, owner ID, instance nonce, and revision, plus only the fields the audience may see. During the question phase the answer should be absent, not blanked; it appears only when the phase is reveal.

How should the display treat stale, duplicate, and conflicting messages?

The accept logic ignores wrong type, session, owner, or a revision no newer than the current baseline, and renders a newer valid snapshot. A different instance nonce for the same session and owner is a conflict and goes to hold, because the disagreement is about which tab is speaking. Out-of-order and duplicate messages both get ignored, but they are different failures.

How should recovery and liveness be handled separately?

Recovery is a pull: the display asks the owner for the current snapshot, and the owner answers with present public state regardless of revision history. Render a waiting state, not a zero, before the first valid snapshot, because absence of data is not data. Liveness uses a heartbeat carrying session, owner, and instance with no revision; do not bump revision on a timer, and only a heartbeat matching the paired session, owner, and active instance resets the timer.

What changes when the operator page is reloaded?

Reloading loses in-memory state and starts a new session with a fresh revision counter. Its revision numbers are not comparable to the old session's. A snapshot from an unrecognized session should go to reconciliation, with a human confirming the score and re-pairing; if the display accepts the new session's first revision, it may show a zero as if the round restarted.

More in Television Browse all articles