Skip to content
Tournaments module

Pool play in the morning, a bracket by the afternoon

Pools, bracket generation, the Tournament Day Builder and the public bracket page are the paid Tournaments module, not the base plan. What they add is the part that goes wrong at two o'clock on a Saturday: working out who finished second in Pool B, writing that team into the right slot, and keeping the draw true as results land. The round-robin scheduling the pools sit on is included.

By the semi-finals, nobody trusts the whiteboard

Pool play overruns by twenty minutes. One person is now standing at a taped-up bracket with a marker, reading four score sheets to work out who came second in Pool B, while somebody argues the tiebreak and somebody else photographs the board for the group chat — where it goes stale the moment the next result comes in. The bracket itself is not the hard part. The arithmetic under it is, and it has to be redone after every match, in a sports hall, with a queue of parents waiting to hear which court they are on next.

How it works

Four steps, no configuration

  1. Draw the pools

    Create pools and assign teams. Standings can then be read for one pool rather than the whole league, and fans can filter a public table by pool. Creating a pool is where the Tournaments module is required.

  2. Slot the day, not the season

    Generate a round-robin for one pool, a chosen set of teams, or the whole league, and put every game in its own time slot on a single day. The round-robin generator itself is included; it needs schedule permission and a league that is not archived.

  3. Seed from what actually happened

    Bracket slots can be placeholders tied to a pool and a rank — "Pool A #1" — that fill in once every match in that pool is final. Or pre-fill the seed list from the standings table and reorder by hand. Seeds are cross-seeded 1 vs N, which keeps the top two apart until the final.

  4. Let results move the teams

    Finalising a bracket match advances the winner into its next slot. There is no separate step for entering a bracket result — the old endpoint for that refuses and tells you to finalise the match. In a double-elimination draw the loser drops into the losers bracket, and a grand-final reset activates when the losers champion wins the first one.

Capabilities

What tournament day actually needs

Everything below is built and running today. Unbadged items are included on every plan; a badge means the capability needs a module added to your plan, a higher plan tier, or a piece of setup finished first.

10 of these are included on every plan. One group needs the Tournaments module.

  • Single-elimination bracket generation

    Tournaments module

    League admins can generate a single-elimination bracket for a league; the generator creates a bracket header plus a real match row for every node, pre-wired with winner-advance pointers.

    Gated behind the paid Tournaments module (TOURNAMENT_POOLS): the tenant must hold an active entitlement AND the module must be toggled on for the league (its catalog default is OFF — backend/prisma/seed.ts:95, backend/src/middleware/requireModule.ts:203-221).

  • Automatic advancement when a match is finalized

    Tournaments module

    Once a bracket exists, finalizing a bracket match moves the winner into its next slot automatically — there is no separate step to record a bracket result.

    Requires a bracket generated through the paid Tournaments module. Manual bracket-result entry is deliberately unavailable: the legacy endpoints return HTTP 409 and tell the caller to finalize the underlying match instead (backend/src/handlers/bracketsHandler.ts:273,281). A tied final with no penalty-shootout result advances nobody — the admin has to break it by editing the match.

  • Pools / groups with per-pool standings

    Tournaments module

    Admins can create pools, assign teams to them, and read standings scoped to a single pool; fans can filter a public league's standings by pool.

    Creating a pool is gated on the paid Tournaments module and restricted to league admins; listing, editing and deleting existing pools are deliberately left ungated (backend/src/handlers/divisionHandler.ts:380,485). Divisions/conferences share the same handler but are never paywalled — the module gate only fires when the path resolves to kind='pool' (backend/src/handlers/divisionHandler.ts:237).

  • Pool-play round-robin scheduling with tournament-day time slotting

    A league admin can generate a round-robin schedule for a whole league, a selected set of teams, or a single pool, and place every game in its own time slot on one day instead of spreading rounds across weeks.

    The round-robin generator itself is not behind the paid Tournaments module — it requires the LEAGUE_MANAGE_SCHEDULE capability and a writable (non-archived) league. Scheduling BY POOL requires pools, and creating pools is paywalled. Odd team counts get a bye each round: the byed team simply has no game that round, and no bye row is written.

  • Automatic pool-to-bracket seed resolution

    Bracket slots can be created as placeholders tied to a pool and rank (shown as e.g. "Pool A #1"), and are replaced with the real team once every match in that pool is final.

    Only slots created with structured seed sources resolve; whole-league seedings are left for the admin to re-seed manually. Resolution is refused when the rank-N team is not uniquely ranked — a genuine tie leaves the placeholder in place, still showing its seed label, and the admin must break the tie by editing the bracket match's teams.

  • Tournament Day Builder (one-day tournament workflow)

    Tournaments module

    A guided builder walks an admin from picking teams and start time, through generating pool play in single-day slot mode, to generating a knockout bracket seeded from the resulting standings, with a live schedule grid for the day.

    Role-gated to LEAGUE_ADMIN/SUPER_ADMIN/PLATFORM_SUPPORT and hidden from navigation unless the league has TOURNAMENT_POOLS enabled AND the tenant is entitled to the paid Tournaments product (frontend/src/layouts/nav/navItems.ts:355-356). It generates single-elimination brackets only — the builder sends no format, so the generator's SINGLE_ELIM default applies.

  • Double-elimination brackets (losers bracket, grand final, bracket reset)

    Double elimination is implemented end to end: the generator builds winners, losers and grand-final rounds, losers are routed into the losers bracket when a match is finalized, and a grand-final reset match is activated when the losers-bracket champion wins the first grand final.

    Same paid-module and admin-role gate as single elimination. Only two entry points expose the format choice — the Scheduler's Generate-knockout dialog and the bracket page's create dialog; the Tournament Day Builder and the Scheduler's one-day tournament wizard never send a format, so both always produce single elimination (the generator defaults to SINGLE_ELIM — backend/src/services/scheduleService.ts:441).

  • Standard tournament seeding (1 vs N cross-seed)

    Seeds supplied in rank order are permuted into standard bracket position order, so the top two seeds are placed in opposite halves of the draw and can only meet in the final.

    Applied automatically by the unified generator; it is not configurable. The `seedingStrategy` field the create endpoint accepts is parsed and discarded (backend/src/handlers/bracketsHandler.ts:36,48). Same paid-module gate as bracket creation.

  • Seed a bracket from current standings

    The bracket builder can pre-fill the seed list with the top teams from the league's current standings table, and the admin can reorder or swap any seed before generating.

    Reads the league-wide standings table; the picker is part of the paid bracket-creation flow. Standings ordering is produced by the sport's own comparator and tiebreak chain, not a tournament-specific seeding rule set.

  • Public, no-login bracket page for fans

    Tournaments module

    A league's bracket can be viewed at a shareable public URL without signing in, showing teams, scores, match status and unresolved slots, with a link surfaced from the league's fan page once a bracket exists.

    Only for leagues that are ACTIVE or COMPLETED, marked public (or unlisted-but-followed by the signed-in viewer), not soft-deleted, and with the PUBLIC_STATS module on — any miss returns 404. Operator-internal fields (venue, court, streaming, payout, timestamps, tenantId, fixtureId, seed-pool refs) are stripped from the public payload, and a slot that has not resolved yet comes back as a null team plus its seed label rather than the placeholder team.

  • Standings tiebreakers used for ranking and seeding

    Standings are ranked by the league's sport-specific comparator with head-to-head and intra-group tiebreak steps applied before point differential.

    The STANDINGS feature module is free and on by default, but a league admin (or platform root) can switch it off, in which case the standings endpoint returns an empty table (backend/src/handlers/standingsHandler.ts:100).

  • Playoff race: clinched, eliminated, magic number

    The standings table marks teams as having clinched or been mathematically eliminated from playoff contention and computes a magic number for teams still in the race.

    The playoff cutoff is hard-coded to the top half of the table (ceil(teams/2)); there is no per-league setting for the number of playoff spots — the schema has no such column. The figures are suppressed under a home/away split view.

  • Broadcast bracket overlay for streams

    A bracket overlay scene renders the league's bracket rounds over a stream and refreshes on an interval as matches go final.

    Part of the paid Streaming & Overlays module (listed at 4900 cents per month in the module registry) and rendered through the overlay base's streaming gate, so it is unavailable to leagues without that module. It reads bracket matches for the league of the current overlay match and refetches every 8 seconds; a league with no bracket matches renders an empty-state card instead of a bracket.

  • Persisted championship record / bracket lifecycle status

    The bracket UI highlights the deciding match as the championship game, and the winner is derived from that match's final score.

    The Bracket row's status column defaults to DRAFT and no code path ever updates it — there is no lock, finish or completed state, and no stored champion field. The champion is a visual treatment on the final match plus the derived winner of that match, not a persisted record: nothing is written anywhere when a bracket finishes.

  • Buying the Tournaments module online

    Tournaments is listed in the module store as a paid product at 2900 cents per month, and a tenant admin can activate it for a 14-day trial from inside the app.

    Online billing is not wired: the subscribe endpoint grants a TRIAL entitlement and returns payment.configured false with the message that online billing is coming soon. After 14 days the entitlement expires and the module gate closes again unless a platform admin grants it directly. Subscribing is itself restricted to a tenant's LEAGUE_ADMIN, SUPER_ADMIN or PLATFORM_SUPPORT.

Questions

Before you ask

How much of this is the paid module?

Pools, bracket generation, the Tournament Day Builder and the public bracket page all need it. Round-robin scheduling, standings and the tiebreak chain the seeding reads are included. Tournaments is listed in the module store and a tenant admin can start a 14-day trial from inside the app; online billing is not wired yet, so the subscribe call grants the trial and reports that payment is not configured. When the trial expires the gate closes again unless a platform admin extends it.

We have 12 teams. Will it add byes for us?

No. The bracket takes exactly 4, 8, 16, 32 or 64 teams and refuses any other count, with a message naming those sizes. Padding a 12-team field out to 16 is your job — there are no automatic byes, no third-place or consolation match, and no bracket export or print file. How many teams come out of pool play is your call, and a play-in game is a fixture you create yourself.

Can we run double elimination, or only a straight knockout?

Double elimination is built end to end: winners bracket, losers bracket, grand final, and the reset match when the losers-bracket champion wins the first grand final. Two places offer the format choice — the scheduler's generate-knockout dialog and the bracket page's create dialog. The Tournament Day Builder generates single elimination.

What happens when two teams finish level in a pool?

A placeholder tied to a pool and rank only resolves when that rank is unique. A genuine tie leaves "Pool B #2" sitting on the bracket and an admin sets the team. The ordering itself comes from that sport's own comparator, with head-to-head and intra-group steps applied before point differential, rather than a tournament-specific rule set.

Can fans follow the bracket without signing in?

Yes, at a shareable URL linked from the league's fan page once a bracket exists — teams, scores, match status and the slots still unresolved, with operator-internal fields such as venue, court and payouts stripped out. The league must be active or completed, marked public (or unlisted and followed by that viewer), and have its public-stats setting switched on; anything else returns a 404. The public bracket page is part of the Tournaments module.

Does the bracket lock once we have a champion?

Not the bracket. It highlights the deciding match and derives the winner from that match's final score, but the bracket row's own status stays DRAFT — there is no finish state and no stored champion field. The lock that matters is on the match: once it is finalised the server rejects further scoring events, which is what guards the result behind the trophy against a late edit.

Turn Tournaments on for your next event

Public leagues need no account to open.