Skip to content
For leagues

Run the whole season from one place

Whether it is an eight-team midweek league or a multi-division season across three venues, the work is the same shape: build the fixtures, score the games, keep the table honest, and give everyone somewhere to look. BallOBall does those four things for 23 sports, and the fan page comes free with the rest.

Today

What running a league usually looks like

Spectators watching a basketball game in a school gymnasium.
  • A spreadsheet holds the fixtures, and one person holds the spreadsheet.
  • Scores arrive by group chat on Sunday night and get typed in on Monday — so the table is wrong for a day and nobody trusts it.
  • The standings argument comes back every season, because the tiebreakers live in somebody’s head rather than in the table.
  • Fans ask “when do we play?” in the group chat, because there is nowhere public to point them.
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.

Set the season up once

Create the league, add teams and rosters, and generate the fixtures. Conferences and divisions if you need them, venues and courts if you have more than one.

  • League creation and multi-league management

    League admins can create and manage any number of leagues inside their organization from a single leagues index, filterable by status and by sport.

    Creating a league requires the LEAGUE_ADMIN role (or platform admin); coaches, players, referees, scorekeepers and fans cannot. League names must be unique within the organization.

  • Season lifecycle (multi-season leagues, activate / end / rollover / archive)

    A league can hold multiple seasons; admins can create, rename, activate and close seasons, archive a finished league to make it read-only, and roll it over into a new league that carries the same teams and division structure with no match history.

    Exactly one season is marked current at a time, enforced in application code rather than by a database constraint. Archived leagues become read-only for teams, rosters, schedule generation and season changes. Roster carry-forward on rollover is opt-in.

  • Conferences and divisions

    Every league can be organised into conferences and divisions with teams assigned to them, edited from a structure tab on the league page, with no paid module required.

    League-admin scope required. Division short names must be unique within a league.

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

  • Player profiles and roster management

    Coaches and league admins can add, edit and remove players on a team roster, set jersey numbers, positions and a captain, with positions validated against the league's sport.

    Roster writes require coach-of-team, league-admin, or a club role grant carrying LEAGUE_MANAGE_ROSTER. Rosters lock ahead of upcoming matches. Jersey numbers are unique per team. Archived leagues are read-only.

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

Let the fixtures build themselves

Round-robin, home-and-away, or a short season with a games-per-team cap. Conflicts are checked as it generates, and you can still override a clash deliberately when the venue insists.

  • Automatic round-robin fixture generation

    Generate a full round-robin schedule for a league from its team list — the app pairs every team using the circle method, inserts a bye when the team count is odd, and writes the fixtures and matches in a single transaction.

    League-admin action only (LEAGUE_ADMIN / SUPER_ADMIN / PLATFORM_SUPPORT, or a club role holding the LEAGUE_MANAGE_SCHEDULE capability — backend/src/handlers/standingsHandler.ts:310). Requires an active season (backend/src/services/scheduleService.ts:227) and at least two non-deleted teams (backend/src/services/scheduleService.ts:214). An archived league is read-only, so a closed season's schedule cannot be regenerated.

  • Double round-robin (home and away)

    Choose a single or a double round-robin; the double pass replays every pairing with home and away swapped.

    The API accepts one or two passes only — a triple round-robin is rejected by the request schema.

  • Games-per-team cap (short-season / partial round-robin)

    Cap the schedule so each team plays at most a chosen number of games instead of the full round-robin.

    Implemented by truncating whole rounds, so with an odd team count the byes still fall where the rotation puts them.

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

  • Deliberate double-booking override

    Leagues that intentionally stack fixtures can force a booking through the conflict check, so the detector never traps an operator who knows what they are doing.

    Available to any caller already authorized to write the schedule; there is no separate approval step or audit-specific role for the override.

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

Score it at the table

Hand a tablet to whoever keeps the book. The console matches the sport, survives the venue Wi-Fi, and locks the result when the game ends.

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

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

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

Stop arguing about the table

Standings build themselves from the results, sorted by the tiebreak chain that sport actually uses — goal difference, win percentage, set difference, whatever is correct. Player leaderboards and the deeper stat tools are a module you can add when the table alone stops being enough.

  • Automatic league standings tables

    Standings are computed automatically from finalized match results and shown both in the admin app and on a league's public fan page.

    Only matches with status FINAL are counted (backend/src/services/standingsService.ts:176). The table is recomputed when the page requests it, not pushed to the browser. The STANDINGS module can be toggled off per league, in which case the endpoint returns an empty list rather than an error (backend/src/handlers/standingsHandler.ts:100).

  • Sport-specific standings columns and tiebreak chains

    Every supported sport declares its own standings comparator and column set in code, so soccer ranks on a 3-1-0 points table, rugby on 4-2-0 with bonus points, cricket on net run rate, and basketball on win percentage with head-to-head — with no per-sport branching in the shared standings engine.

    The tiebreak order is defined in each sport's plugin source, not configurable by a league admin — there is no UI or league-rules field for reordering tiebreakers. Head-to-head is consumed only by basketball's comparator; the other 22 comparators fall through to their own primary key and then point differential and points for, with cricket inserting net run rate and archery a 10s/Xs countback.

  • Home/away splits, division (pool) scoping and season scoping on standings

    Standings can be filtered to overall, home or away records, scoped to a single division or tournament pool, and scoped to a chosen season.

    Pool scoping counts only games where both teams are in that pool. Season scoping requires the league to have Season rows; with no season selected the table is all-time for that league.

  • Per-sport box scores for every match

    Every match has a box score built by its sport's own projection of the recorded scoring events, and each of the supported sports ships its own box-score table layout in the app.

    Anonymous access is allowed only when the league is public and ACTIVE or COMPLETED, when the match has streaming enabled, or when the viewer follows the league (backend/src/handlers/matchHandler.ts:277-318). Per-player minutes are derived, not directly recorded: they are reconciled from SUBSTITUTION events plus clock progression, only for sports with a real countdown clock, and a side whose opening lineup cannot be established keeps zero minutes flagged minutesReliable false.

  • Player leaderboards ranked by sport-specific metrics

    Leaderboards module

    Leagues can publish leaderboards that rank players by the metrics their sport defines — points and rebounds for basketball, runs and wickets for cricket, goals and assists for soccer.

    The LEADERBOARDS feature module ships DISABLED by default (infrastructure/lambdas/db-init/platform-features.sql:1355, :1697) — a league admin must turn it on before any player numbers are served; until then the endpoint returns an empty list. A single leaderboard ranks by ONE metric at a time, capped at 100 rows and defaulting to 25; an omitted or unsupported metric falls back to that sport's first published metric.

  • Stat Explorer — sortable, filterable season-stats grid

    Leaderboards module

    A season-stats grid shows every player against every metric their sport publishes, sortable by any column and filterable by team, division, games played and player name.

    The page lives inside the signed-in per-sport section (/s/:slug/explorer) — there is no public fan-page route for it. Metric values and columns are withheld unless the LEADERBOARDS module is on (backend/src/handlers/standingsHandler.ts:180-190). The grid reads at most 500 players in one request (backend/src/services/leaderboardService.ts:384) and sorts, filters and exports in the browser; there is no pagination and no server-side query.

Run the play-offs when the season ends

Seed a bracket straight from the final table, and let results advance the winners for you. Brackets are part of the Tournaments module rather than the base plan — the regular season does not need it.

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

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

Give everyone somewhere to look

Every league gets a public page with the schedule, the table and live scores. No account, no app, and a calendar feed people can subscribe to.

  • Public league fan page (league microsite)

    Every league can publish a branded public page at balloball.com/l/<league-slug> that fans open in a browser.

    Opt-in, not automatic: a league admin must flip the league to public (POST /leagues/{id}/visibility), which auto-enables the free PUBLIC_STATS module. Only leagues in ACTIVE or COMPLETED status are served; DRAFT and soft-deleted leagues 404. An unlisted league also 404s for anonymous visitors and for signed-in non-followers — only a signed-in follower (they redeemed the join code or accepted an invite) can read it.

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

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

  • Calendar subscription for schedules (iCal / webcal)

    Fans can subscribe a league, team, venue or single match schedule into Apple Calendar, Google Calendar or Outlook, and reschedules flow through to their calendar.

    Public feeds honour the same public-league gate; private league feeds return 401 in the fan's calendar client. Each feed returns a sliding window of the last 90 days plus the next 365 days, capped at 1000 events.

Questions

Before you ask

We run more than one sport. Do we need more than one account?

No. One organisation can run leagues in any of the 23 supported sports, and each league gets that sport’s own scoring, standings columns and terminology. It is one login and one set of teams and people.

Do fans need an account to see the schedule?

No. A public league page is readable with no account and no app download — the schedule, the standings and live scores all render for an anonymous visitor. Signing in is only for the people running or playing in the league.

Can we start without paying?

Yes. An organisation is self-serve to create, and the core of running a league — fixtures, scoring, standings, and the public fan page — is included. Some capabilities are sold as modules you add to your plan; those are badged as such throughout this site.

What if the fixture generator gets a clash wrong?

Every fixture can be created, rescheduled, postponed or cancelled by hand, so the generator is a starting point rather than a cage. It checks for team double-bookings as it goes and reports a summary; if you genuinely need a clash, you can override it deliberately.

Does it handle our sport properly, or is it a football app with the words changed?

Each sport is a separate engine with its own event catalogue, period structure and standings columns — cricket counts overs and wickets, bowling counts frames, tennis counts sets. The server rejects an event type the match’s sport does not recognise.