The fold, and the simulator
A public page for everyone who is not on the adjudication core: the points stack folding into the break, the break itself, the break-round bracket — and a simulator where anybody can play out the rest of the tournament themselves.
That sample is a real tournament — a 110-team intervarsity, with its real teams, real break and real speaker tab. Not because we were careless with somebody's data: because everything this page can show was already public on that tournament's own Tabbycat, so the honest demo is the real thing. If that claim is wrong the whole tool is wrong, and you can check it — open the What's shown tab on the sample and it lists, switch by switch, what the tab has released and what it has not.
The other two tools' samples are invented, because those two publish things no tab makes public.
The problem it solves
Between the last prelim and the break announcement, a few hundred people all want the same thing: to work out who is breaking. They do it on paper, badly, in a hallway, and then argue about it. And once the break rounds start, nobody outside the room can follow which room feeds which — so the bracket exists only in the heads of the people who drew it.
Both of those are just a rendering problem. The tab already knows everything required; it is simply not shown that way. This is that page.
It shows only what your tab has already made public. Not “the parts we judged safe” — literally the switches you have already set in Tabbycat. If results are not public, they are not on the page. If a round is silent, it stays silent. If the draw is not released, the rooms are empty. Turning something on in Tabbycat turns it on here at the next refresh, and nowhere else. See how that is enforced.
The fold
- 1What your tab is currently saying
The round in play, whether its draw is out, whether its rankings are public. Read from the tab, not asserted by us.
- 2Break categories come from the tab
Their names, their sizes and how many there are. Nothing here knows what your categories are called.
- 3Scrub back through the tournament
How the fold looked after any earlier round. Useful for the argument about who would have broken if.
- 4The team on the line
Named, with the points total that got them there — which is the first thing anyone asks.
- 5One row per points total
Grouped by bracket rather than listed 1 to 48, because that is how a break is actually read.
A sub-category's break line is not the top N of its own stack. This caught us out live. If two teams eligible for a sub-category break the main category instead, they are marked as such and sit above the sub-category's line — so cutting after the first N positions strands the last teams that genuinely broke. The line is drawn after the last team that actually broke, and the page says so: “8 teams, with 2 above the line out of this break”. The demo tournament reproduces the condition on purpose, so the fix stays tested.
The break rounds
The bracket, drawn so you can see which room feeds which. Two things about it are worth stating plainly, because getting either wrong makes a correct bracket look broken:
- Rooms fold, they do not pair off with their neighbour. With P rooms in a break round, room i meets room P+1−i. So with eight octofinal rooms it is 1+8, 2+7, 3+6, 4+5 — not 1+2, 3+4.
- The fold happens once, at the break, and nothing is re-seeded afterwards. Survivors keep their break seed and follow a fixed tree. This is the part people most often assume works the other way.
Which means everything past the first break round is genuinely knowable in shape and genuinely unknowable in occupants — so the shape is drawn and the rooms stay empty until the round before has been debated.
The speaker tab
Once a tournament releases its speaker tab, the fold shows it — and only
then. The tab appears in the navigation when Tabbycat's own
speaker_tab_released switch is on, and disappears again if it is
turned off.
- 1It says how far the tab goes
Tabbycat lets a tournament publish only the top N. Where that is set, the list stops there and the page says so rather than looking truncated by accident.
- 2Anonymous speakers keep their scores
A speaker the tab marks anonymous is shown without a name. The ranks stay continuous and nothing is invented.
- 3Iron-person speeches are counted out
Tabbycat excludes them from the average, so this does too — and marks them, so a short speech count has a visible reason.
- 4Round by round, per speaker
Each speech, in round order, with an asterisk on the ones that do not count toward the average.
Three of Tabbycat's rules here are obeyed, not reimplemented, because
each is a decision the tournament made and not ours to second-guess: the
anonymous flag on a speaker, the tab limit, and the exclusion of
iron-person speeches from an average.
And three things are not published even when the speaker tab is: reply speeches, the adjudicator tab, and any ballot or margin. The first two have their own release switches in Tabbycat, and releasing the speaker tab does not release them — conflating those would publish something a tournament deliberately held back.
What this cost, and what replaced it
The gate's sharpest check used to be no float anywhere in the payload. Speaks and feedback averages are floats in Tabbycat while points and counts are integers, so any float at all was evidence that something score-shaped had got in — whatever it was named, and wherever it came from. That is a much stronger check than trying to enumerate the fields you do not want.
Publishing a released speaker tab means floats are now legitimate. So the
check was narrowed rather than deleted: no float outside
speaker_scores[].total|avg|stdev,
speaker_scores[].by_round[][].score and
standings[].speaks. Everywhere else the tell still works.
Two more checks were added alongside it, because a switch that is off has
to be provable: when speaker_tab_released is off there must be no
speaker_scores key at all — not an empty one, absent — and when
it is on, no row marked anonymous may carry a name, and no rank may exceed the
published limit. The demo tournaments cover both states, and the one whose tab
is closed exists specifically so the off-path is tested rather than
assumed.
The pull is careful in one more way. A Tabbycat speaker record carries an
email, a phone number, a barcode and a url_key — which is
that person's private ballot URL. So the pull takes the name, the team and the
anonymous flag, and leaves the rest in the response object it never stores.
The simulator
- 1The first round is folded off the break
Room 1 takes seeds 1, 8, 9 and 16 here. The rule is derived from the break size and how many teams are in a debate, so it reads correctly for a two-team break too.
- 2This round is already decided
Where a result is public, the simulator locks it to what actually happened rather than letting you re-run history.
- 3This one is yours to pick
Per room, not per round — ballots land one room at a time, so a half-decided round shows its results and stays pickable elsewhere.
- 4Room 1 meets room 4, not room 2
Four quarterfinal rooms, so room i meets room P+1−i. The lines are drawn so no connector crosses another.
- 5Honest about what it cannot know yet
“Waiting on OSF rooms 1 and 2” rather than a blank box or a guess.
- 6Chalk, Surprise me, Share, Clear
Share produces a link that encodes your picks. Nothing is sent anywhere; it is all in the address.
The simulator is a separate tab from the real bracket on purpose. One is your adjudication core's answer and the other is a spectator's guess, and those should never be a click apart from looking like the same thing.
Picks never leave the browser Technical
Picks live in localStorage and in a
#sim=<slug>.<digits> fragment. A URL fragment is
never sent to a server, so a shared bracket is shared peer-to-peer through
whatever you pasted the link into. The share code is fixed width — one
character per seat, x for unpicked — because a variable-width
code let a half-finished round shift every later digit by one, and a shared
link decoded into somebody else's bracket.
The page makes no network requests at all after it loads, and the test
suite asserts that: it checks the inlined script contains no
fetch, WebSocket or XMLHttpRequest.
It keeps itself current by navigating — a full reload once the page
is five minutes old, and only when nothing is open, the tab is visible, and
nobody has typed for two minutes — which is why the “makes no requests” claim
and the content security policy both still hold.
How “only what is public” is enforced
Two independent mechanisms, because one would not be enough to trust.
- Rules, re-read every build
One module decides what a spectator may see, and it reads your tab's own preferences every time rather than remembering them. Rankings need results public and the round finished and the round not silent. Rooms and panels need the draw released and your public-draw setting to permit it. The break needs breaking teams to be public. A motion needs public motions and that round's motions released.
So the page follows your tab rather than tracking it. Turn something off in Tabbycat and it comes off the page on the next refresh.
- An allowlist, checked against the finished page
Rules stop the wrong values. They cannot stop a wrong field — one extra key added in a hurry, carrying something private. So every key that may appear in the published data is declared, and the build walks what it is about to publish and refuses anything undeclared. A new field cannot leak by accident; it has to be declared, which means somebody has to think about it.
- And the checks are the deploy gate
./refreshwill not publish a build that fails them. Not warn — refuse.
The sharpest check is “no float anywhere” Technical
Speaker scores and feedback averages are floats in Tabbycat. Points and counts are integers. So a float anywhere in the published payload is the tell that something score-shaped has got in, wherever it came from and whatever it is called — a much better check than trying to enumerate the fields you do not want.
The others: per-round points exist only for rounds whose rankings are
public; every points value is inside this format's scale, read from
teams-per-debate rather than assumed to be 3/2/1/0; no room name or panel
outside the rounds whose draw is public; silent rounds are silent; the script
makes no network call; the only outside references are the font host and your
own tab. See fold/gate.py and
fold/tests/test_gate.py.
Two failures that taught us something
Both of these happened live, and both are now guarded, which is more useful to you than a claim that nothing goes wrong.
An empty break is a real state, so it must never be inferred from a failure. A transient error on the break endpoint was being swallowed and returning “no break”, so the site published “the break has not been announced” over a break that had been out for a day. No error, no log line — the page just forgot who broke.
Now: the endpoint is retried and then raises; and separately the build compares against the previous pull and refuses to publish a category that had ranked teams and now has none. The retry alone cannot catch it, because a successful response with an empty body looks exactly like “not announced yet”.
“Results are public” is not the same as “the result is in”. A round was un-silenced before a single ballot was confirmed. The page read the flag, concluded the round was decided, locked the simulator, and left every later round waiting forever.
Now the ballots decide, not the flag — and per room, because ballots land one room at a time. A decided room shows its result while its neighbours stay pickable.
On the sample, “Round by round” is empty, and that is the tool working. That tournament has finished and has since switched its public draw off, so there are no rooms or panels to show. The fold does not cache what a tab used to allow.
Publishing it
The whole site is one static HTML file plus a headers file. That is a deliberate constraint rather than an aesthetic one: a break announcement is the one moment a tournament page gets hammered, and a single file on a static host does not fall over.
Cloudflare Pages is the default because it is unmetered on bandwidth. Netlify
and surge are also supported — one word in tournament.json.
We learned that the hard way. A free hosting tier ran out of bandwidth credits mid-tournament and refused every deploy for eighteen hours, freezing the live page on a stale build right through a break-round draw going out. Worse, the same provider's own quota endpoint reported plenty of credit remaining while refusing every deploy — so do not trust a quota reading, and do pick a host that does not meter bandwidth.