Tabbycat Adjcore Toolkit

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

Every team stacked by points, with the break line cut across it. This is the page people actually want between the last prelim and the announcement.
Every team stacked by points, with the break line cut across it. This is the page people actually want between the last prelim and the announcement.
  1. 1
    What 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.

  2. 2
    Break categories come from the tab

    Their names, their sizes and how many there are. Nothing here knows what your categories are called.

  3. 3
    Scrub back through the tournament

    How the fold looked after any earlier round. Useful for the argument about who would have broken if.

  4. 4
    The team on the line

    Named, with the points total that got them there — which is the first thing anyone asks.

  5. 5
    One 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:

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.

The speaker tab, on a tournament that has released it. Every number comes from the tab's own speaker tab; nothing is recomputed.
The speaker tab, on a tournament that has released it. Every number comes from the tab's own speaker tab; nothing is recomputed.
  1. 1
    It 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.

  2. 2
    Anonymous speakers keep their scores

    A speaker the tab marks anonymous is shown without a name. The ranks stay continuous and nothing is invented.

  3. 3
    Iron-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.

  4. 4
    Round 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

Run your own break. Pick who goes through and the next round re-forms — following the same fixed tree the real rounds do.
Run your own break. Pick who goes through and the next round re-forms — following the same fixed tree the real rounds do.
  1. 1
    The 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.

  2. 2
    This round is already decided

    Where a result is public, the simulator locks it to what actually happened rather than letting you re-run history.

  3. 3
    This 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.

  4. 4
    Room 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.

  5. 5
    Honest about what it cannot know yet

    “Waiting on OSF rooms 1 and 2” rather than a blank box or a guess.

  6. 6
    Chalk, 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.

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

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

  3. And the checks are the deploy gate

    ./refresh will 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.


Next: consolidated judge feedback →