Skip to content
For tournaments

Pools in the morning, a bracket by the afternoon

A one-day tournament is a scheduling problem with a scoreboard bolted on. BallOBall generates pool play into individual time slots across every court you have, carries the pool tables into a seeded knockout, and publishes a bracket link teams can check between games. Pools, brackets and the Tournament Day Builder are the paid Tournaments module. Round-robin fixture generation is free core.

Today

Tournament day, as it usually runs

A tennis player on a hard court seen from above, mid-rally.
  • The draw is a whiteboard by the desk, and every finished pool game means rubbing out a name and writing in another.
  • Six courts going at once, forty minutes behind by lunch, and every over-running pool eats into the knockout.
  • Two teams come out of the pool at 2-1 and the tiebreaker gets settled at the bracket table, in front of both coaches.
  • The same trestle table is the gate, the results desk and lost property, so every team asking which court they are on next joins the entry queue.
With BallOBall

The same season, without the spreadsheet

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.

Build the draw before the doors open

Teams, rosters, pools and the surfaces they play on, entered the night before instead of on a whiteboard at 8am. Pools are part of the paid Tournaments module; teams, rosters, venues and named courts are not. If a division carries age bounds, an over-age player is refused at the roster rather than discovered on finals day.

  • Teams and coach assignment

    League admins can create teams within a league, set team branding, assign and remove coaches, and remove a team.

    Team creation is scoped to admins of that league's organization; cross-organization writes are refused. Team short codes must be unique within the league. New teams cannot be added to an archived league.

  • Bulk roster import by paste or CSV

    Admins can paste rows or upload a CSV of players, preview the parsed result with per-row errors before committing, and see a per-row success/failure report with a retry for the failures.

    Parsing and validation happen in the browser and rows are submitted one HTTP request at a time — there is no server-side batch endpoint, so a large import is a sequence of individual creates.

  • 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).

  • Venues and courts

    Each league can define venues with structured addresses, capacity, amenities and named courts, and courts can be pre-bound to a division or pool so the scheduler can suggest them.

    Venues belong to a league (not shared org-wide) and are managed by league admins.

  • Named courts / playing surfaces per venue

    Split a venue into named courts or surfaces, and add, rename, delete or reorder them as the building changes.

    Court names are a venue-configuration surface; assigning a court to a match is a manual choice in the match dialog, not an automatic allocation.

  • Age-based division eligibility enforcement

    Divisions and pools can carry minimum and maximum age bounds, and the platform blocks adding an out-of-age player to a team in that division — both when an admin adds them to a roster and when someone registers.

    Requires a birth date on the player; league admins and above can apply an explicit eligibility override.

Slot the whole day across every court

Generate the pool round-robin as one calendar block: every game gets its own start time, spaced by the slot length you set, with a break inserted where lunch goes. Round-robin generation and the slot spacing it uses are free core. Generating pool by pool needs pools, and the guided Tournament Day Builder that strings the day together is the paid Tournaments module. Team clashes are reported against existing fixtures before anything is written, and any single game can still be moved, postponed or cancelled by hand when a court runs long.

  • Tournament-day scheduling (one-day block with slot spacing and a lunch break)

    Run a whole tournament day in one calendar block: every generated match gets its own start time spaced by a slot length you set, with an optional break inserted after a chosen match.

    Admin-only route (frontend/src/app/router/router.tsx:768). The round-robin/pool-play half is free; the knockout step of the Tournament Day Builder calls the paid, module-gated knockout generator. Slot spacing is a fixed interval (45 minutes unless you change it) — it does not read court rental hours or venue availability, so nothing checks that the day fits inside the hours you booked.

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

  • Pool / division scheduling (a round-robin per pool)

    Tournaments module

    Generate a separate round-robin inside each pool, so a group stage schedules pool by pool.

    Creating a pool is gated on the paid TOURNAMENT_POOLS module (backend/src/handlers/divisionHandler.ts:237), which is a PRO-tier, per-league opt-in that ships disabled by default (backend/prisma/seed.ts:95). Divisions that are not pools are ungated.

  • Team double-booking detection

    If you try to schedule or move a match into a window where one of the two teams is already playing, the app blocks the save and names the clashing fixture.

    It is a soft block: an authorized league admin can force the booking through by re-sending with an override flag (backend/src/handlers/matchHandler.ts:1059). Bulk generation never blocks — it returns the overlaps as warnings instead (backend/src/services/scheduleConflictService.ts:288).

  • Bulk-generation conflict summary

    When you generate a batch of fixtures, the app checks the whole plan against existing matches before writing anything and reports the overlaps it found.

    Warning only — generation never blocks. It compares against pre-existing matches; overlaps inside the batch itself are not reported, because whole rounds are deliberately scheduled at the same time for multi-court venues. Venue conflicts are not part of this check either — the generators never assign a venue.

  • Manual / custom fixture creation, reschedule, postpone and cancel

    Create one-off matches by hand with a date, time, venue and court number, then move, postpone or cancel any match from the schedule manager.

    Admin surface — the schedule manager route is restricted to SUPER_ADMIN, PLATFORM_SUPPORT and LEAGUE_ADMIN (frontend/src/app/router/router.tsx:812); a custom club role holding LEAGUE_MANAGE_SCHEDULE can move matches via the API. Cancel is a status change, not a delete — the row stays on the schedule and can be moved back to SCHEDULED or POSTPONED (backend/src/handlers/matchHandler.ts:1302). Deleting it is a separate action.

Open the doors and run the gate

Ticketing is free to switch on and admin-controlled. Cash and free entry work end to end today: mint and print a sheet of signed QR cards, take at-the-door sales on a box-office channel, and scan with a phone camera. A duplicate or wrong-event ticket is named as such at the lane. Card and PayPal checkout are built but stay dormant until a payment provider is connected, so a priced ticket is money you collect yourself for now.

  • Self-serve Ticketing module activation

    An organization admin can switch Ticketing on for their organization from inside the product, without contacting support, and switch it off again once no tickets are outstanding.

    Restricted to LEAGUE_ADMIN / SUPER_ADMIN / PLATFORM_SUPPORT. Disabling is deliberately blocked while any unredeemed ticket or unpaid order exists, because revoking the entitlement makes gate scanning reject already-sold tickets.

  • Printed cash tickets with signed, offline-verifiable QR

    An admin can mint a batch of physical admission tickets for one ticket type and print a sheet of QR cards, each carrying an HMAC-signed token that the gate scanner verifies cryptographically before any database lookup.

    Batch size is capped per request in the schema. Reserved-seating ticket types cannot be printed in bulk. Printing is the browser's print dialog on a generated sheet — there is no ticket-stock or thermal-printer integration. Rotating the HMAC signing secret invalidates every already-printed ticket unless the old value is left in the secret's `previous` slot, which the scanner still accepts.

  • Cash / box-office sale at the door

    Staff can record an at-the-door sale in the box-office panel, which creates an already-paid order on a BOX_OFFICE channel, issues the tickets and posts the sale to the ledger.

    Admin/ticketing-operator authorization plus the Ticketing entitlement are required. The money itself is collected outside the product; the app records the sale.

  • Free / $0 ticket issuance with instant QR

    For a free ticket type, an anonymous fan can claim tickets from the public event page and the tickets are issued immediately as scannable QR codes on their device.

    Only the free path completes end to end without payment configuration; a priced ticket cannot be paid for online in the current production deployment.

  • Gate scanning (camera QR plus manual code entry)

    Staff at the gate can scan a ticket QR with their phone camera in the browser, or paste the code manually, and the server redeems the ticket and reports valid, already-scanned, cancelled, wrong-event or not-recognized.

    Requires a live network connection — every scan is a server call, there is no offline mode. Camera scanning uses the browser's native BarcodeDetector (Chromium and iOS 17+ Safari per the source comment); other browsers fall back to manual entry. Restricted to SUPER_ADMIN / PLATFORM_SUPPORT / LEAGUE_ADMIN plus GATE_AGENT on the scanner route, and a gate agent can only scan a match they hold an active gate assignment for.

  • Duplicate-scan and wrong-event protection at the gate

    A ticket that has already been scanned is reported as already scanned with its entry time, a cancelled ticket is reported as cancelled, and a genuine ticket presented at the wrong fixture is rejected as being for a different match.

    The redeem step uses a guarded update; the source notes a racing double-scan is resolved by the atomic guard so only one entry is admitted, but this is described as acceptable for gate use rather than a formal uniqueness guarantee.

  • Live gate / capacity dashboard for a fixture

    Ticketing module

    Admins can open a per-match admission view showing tickets sold, how many have scanned in, how many are still outstanding, a per-ticket-type split and the most recent scans.

    Read-only, admin-only, entitlement-gated; the figures refresh when the page is loaded or refetched rather than being pushed to the browser.

Score every court at once

One tablet per court. The console matches the sport being played, and the scorekeeper dashboard lists games live-first so a volunteer taking over mid-morning can see what to open next. A set or race sport locks the moment the match is decided, and finalising closes the book, which is designed to hold a result still while the bracket is being drawn. If the hall Wi-Fi drops, taps keep landing in a queue on the device.

  • Sport-aware scorekeeper console for 23 sports

    BallOBall ships a scorekeeper console for every one of its 23 supported sports, and the console for a given match is selected automatically from that match's sport.

    Twenty-two sports render a dedicated sport-pack console; basketball uses the original ScorekeeperPage (frontend/src/sports/basketball/slots.ts:9 deliberately ships no console slot). The route is role-gated to SUPER_ADMIN, PLATFORM_SUPPORT, LEAGUE_ADMIN and SCOREKEEPER — coaches, players and fans cannot open it.

  • Scorekeeper game queue

    Scorekeepers get a dashboard listing the league's games in priority order — live first, then today, then the rest of the week — with one-tap entry into the console.

    The file states there is no per-match scorekeeper assignment system today; every match in the league is visible to every scorekeeper (frontend/src/pages/app/ScorekeeperDashboardPage.tsx:5).

  • Offline-tolerant scoring with a durable queue

    If the connection drops mid-game the scorekeeper keeps tapping: events are written to a durable on-device queue that survives closing the tab, and they are sent in order automatically when connectivity returns.

    The queue protects events captured after the console has loaded. The service worker caches only public read endpoints, never authenticated data (frontend/src/pwa/runtimeCaching.ts:7), so a console cannot be opened cold with no connectivity. Deterministic failures — a 4xx — mark the queued row errored and surface it to the operator instead of retrying forever, and a write that cannot reach IndexedDB at all rejects rather than silently dropping the event.

  • Server-backed undo of a recorded event

    Any recorded event can be undone from the play-by-play log; the server deletes the event and reverses its score and timeout effects in the same database transaction.

    On a match already marked FINAL, undo is refused unless the caller is platform staff (SUPER_ADMIN / PLATFORM_SUPPORT) — a scorekeeper must ask for the match to be reopened. Archived leagues are read-only.

  • Match-decided lock in set, game and race sports

    In the set, game and race-target sports, score-moving controls switch off once one side has clinched the match, so play cannot be recorded past the deciding set or leg.

    Implemented in the consoles for the set/game/race family only (badminton, pickleball, tennis, table tennis, squash, racquetball, volleyball, darts, pool). Bowling and curling are excluded by design. It is a client-side guard — the server enforces only the FINAL-match lock, so a decided match that has not been ended can still be written through the API.

  • Finalized-match lock ("book closed")

    Once a scorekeeper ends the game, the server refuses any further scoring events on that match, so the official result cannot be moved after the book is closed.

    Reopening a finalized match to score again is restricted to platform staff. The End-game dialog warns when locally queued events are still syncing, but finalizing is not blocked on the queue draining.

Turn the pool tables into a bracket

This is the paid Tournaments module, and it takes two switches: the organisation needs the entitlement and the league needs the module turned on, which ships off. With it on, pre-fill the seed list from the current table, place a slot as "Pool A #1" before that pool has even finished, and let a finalised match carry the winner into the next round without anyone retyping a result. Seeds are cross-seeded 1-vs-N, which keeps the top two apart until the final.

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

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

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

  • 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).

  • 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).

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

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

Tell every team where they play next

One link answers the question the desk is asked all morning. The schedule, the pool tables and the individual match pages read in any browser with no account and nothing to install. The public bracket page is part of the Tournaments module, and it needs the league set to public with the public-stats module on. An event you would rather keep off the directory can stay unlisted and hand out an 8-character code instead.

  • Public schedule / fixtures

    A published league's fixture list and upcoming games are readable on its public fan page without signing in.

    Gated on the league's SCHEDULING module (FREE tier, default on) plus the PUBLIC_STATS anonymous-read gate.

  • Public standings table

    Anonymous fans can read a published league's standings table on its fan page.

    Gated on the league's STANDINGS module. STANDINGS is a FREE-tier module that is enabled by default (backend/prisma/seed.ts:88), but a league admin can turn it off, in which case the tab disappears and the anonymous read returns an empty list.

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

  • Live match page with auto-updating score

    Anyone with a match link can open a live match page that updates the score and play-by-play automatically while the game is in progress, without signing in.

    Updates use HTTP long-polling (the server holds the request open for up to 8 seconds and returns a batch), not WebSockets or server-sent events — API Gateway buffers responses so true streaming is not available. Polling stops once the match is FINAL, CANCELED or POSTPONED; alongside the stream the page refetches the score every 4 seconds, backing off to 20 seconds while the long-poll is healthy.

  • No account and no app download needed to view a fan page

    Fans can open a public league page, its standings, schedule, teams, players and individual match pages in any web browser without creating an account or installing anything.

    Following a league/team/player, the personalised feed, notifications, loyalty and ticket wallet all require a signed-in account.

  • Join a private league with a code

    A league can hand fans an 8-character code that unlocks its page, including for leagues that are not listed publicly.

    Rate-limited to 30 attempts per minute per IP. Redeeming persists a durable follow only for a signed-in caller; an anonymous redeemer gets the league details for that session but no saved membership.

Questions

Before you ask

Are the brackets included, or do we pay for them?

You pay. Brackets, pools, the public bracket page and the Tournament Day Builder are the paid Tournaments module, and it needs both an organisation entitlement and a per-league toggle that ships switched off. An organisation admin can start a 14-day trial from the module store inside the app; online billing is not connected yet, so carrying on past the trial is arranged with us rather than by card. Round-robin fixture generation, live scoring, standings and the public schedule sit in the free core and work without it.

Can the bracket seed itself from pool play, or do we re-enter everything?

It can, if you build the draw with structured pool seeds. A slot created as "Pool A #1" carries that label until every match in that pool is final, then fills with the team that finished there. The exception is a genuine tie the standings tiebreakers cannot separate: the placeholder stays and an organiser sets the team by hand, deliberately, rather than the app inventing an order nobody agreed. A draw seeded from the whole league table instead of from pools is re-seeded manually.

We have 12 teams. What does the draw do with the odd numbers?

You handle that, not the app. Bracket sizes are 4, 8, 16, 32 and 64, and the generator wants exactly that many seeded teams before it will build — there are no automatic byes. With 12 teams you either play a preliminary round down to 8, or choose 16 and decide yourself who sits in the spare slots. Two more things worth knowing while you plan: the bracket model has no third-place play-off, and the bracket is a screen and a shareable link rather than a printable or downloadable draw sheet.

Six courts at once — can one person keep up with the scoring?

It is not meant to be one person. Each court takes its own tablet and its own console, and the scorekeeper dashboard orders that league's games live first, then today, so whoever picks up a spare tablet sees what to open. Be aware there is no per-match scorekeeper assignment today: every scorekeeper sees every game in the league, so the ordering is an aid rather than a lock. On the desk side, moving a game names the fixture it clashes with, and an admin who knows the hall can force the clash through.

Do teams need an account to find out when they play next?

No. The schedule, the pool tables, the bracket and the individual match pages open in a browser with no sign-up and no app download. A match page keeps its score and play-by-play updating while the game is in progress. The league page itself is a snapshot that refreshes when someone reloads or navigates rather than a continuously ticking scoreboard, so point teams at the match page for a live game and at the bracket page for the draw.

Can we take gate money through this on the day?

Cash and free entry, yes. Print a sheet of QR cards whose codes the scanner verifies cryptographically before it touches the database, record at-the-door sales on a box-office channel, and watch sold-against-scanned on a live gate view. Online card and PayPal checkout are written and waiting on a connected payment provider, so a priced ticket is collected offline in the meantime. One practical constraint for a leisure-centre car park: every scan is a server call, so the gate needs a working connection — there is no offline scan mode.