Built for the gym that changes sport every six weeks
A rec centre is not a league with a smaller budget. It is short seasons back to back, courts booked solid from four o'clock, sign-ups arriving by phone and on paper, and a scorer's table staffed by whoever turned up. BallOBall takes the sign-up online, puts the facility and the fixtures in the same place, and hands the table a tablet that already knows which of 23 sports it is looking at.
Northside Youth Flag Football
Fall 2026 · registration open
Player's full name
Date of birth
Division
U12 Coed · 5-on-5
Sat 9:00am · 20-min half × 2
Age-eligible. U12 accepts players born 2014–2016.
Preferred position
optionalParticipation waiver
Typed signatureI am Maya's parent or legal guardian and I accept the participation waiver and concussion protocol.
Daniel Okafor
Summary
What the front desk deals with every season
- Sign-ups arrive three ways — the front desk, a voicemail, and a paper form in a folder — and none of the three agree by the end of week one.
- Volleyball finishes Thursday and basketball starts Monday on the same four courts, so the facility calendar is the real schedule and it lives in a different system entirely.
- Nobody at the table has done this before — the desk supervisor changes with the term, and Saturday's scorer is a parent from the four o'clock class who will not be here for the winter intake.
- Half the players are drop-ins who never make it onto a roster, so the sheet you print on Friday is already wrong by Saturday morning.
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.
Take the sign-up off the clipboard
Publish a form, share one link, and let a parent sign a child up without creating an account. The form builder, its custom questions and the waiver are part of the Registrations module, and that module has to be switched on for the league before the public form will accept anyone. Confirming a registration puts the player straight onto the roster, and a form with a capacity limit runs a waitlist that promotes the next person when a place frees. Card payment is written and routed but activates only once a payment provider is connected — until then a paid form holds the place and tells the registrant the programme will collect the fee directly. Scholarship waivers are recorded against the registration either way.
Public sign-up page (no account required)
A parent can open a shared registration link and sign a child up without creating an account.
Only works while the form status is PUBLISHED, the league is not archived, the open/close window is current, and the REGISTRATIONS module is enabled — otherwise the page shows a closed/not-found message (registrationHandler.ts:1409-1427).
Online registration form builder
Registrations moduleLeague administrators can build and publish registration forms with custom questions, an optional waiver, fees, capacity limits and an open/close window.
Admin surface is role-gated to SUPER_ADMIN / PLATFORM_SUPPORT / LEAGUE_ADMIN (router.tsx:1006) and the league's REGISTRATIONS feature module must be enabled before the public form will accept anyone (registrationHandler.ts:1411).
Waivers with typed e-signature and versioned snapshot
A form can require a waiver; the registrant ticks acceptance and types a signer name, and the exact waiver text and its version are stored with the registration and can be viewed later.
Module-gated with the rest of registration. Signature capture is a typed name plus a checkbox, not a drawn signature or a third-party e-signature service.
Registration waitlist with automatic promotion
Capacity-limited registrations and camps support a waitlist, and freeing a seat promotes the next person waiting.
Only applies to forms and camps that have a capacity set; an uncapped form never triggers the capacity gate.
Automatic roster enrollment from confirmed registrations
Confirming a registration creates the corresponding player on the roster, and the operation is idempotent so an already-enrolled registrant is not duplicated.
Fires on confirmation, which today is an admin action on the offline path; module-gated with the rest of registration.
Scholarships / fee waivers
A league admin can grant a scholarship or fee waiver against an individual registration, and the waived amount is recorded on the registration.
Admin-only action (LEAGUE_ADMIN of the form's league); it records a waived amount rather than moving money.
Card payment for registration fees (Stripe Checkout)
Needs a connected payment providerThe code path that creates a Stripe Checkout Session for a registration fee is implemented, and until Stripe keys are configured a paid form instead reserves the registrant's spot and tells them the league will collect the fee directly.
DORMANT. stripeService detects the PASTE_* placeholder secret and returns a null client (stripeService.ts:87-95), so createRegistrationCheckout returns {configured:false} and the submission falls back to the offline message 'Online payment isn't enabled yet' (registrationHandler.ts:1878-1879). The registration row is still created; the operator collects the fee offline and confirms the registrant from the ledger.
One organisation, every sport in the building
Run volleyball, pickleball, basketball and futsal side by side under one login, each with its own rules, terminology and scoring model — all 23, with no per-sport charge. A sport is enabled once for the organisation before you can create leagues in it, and the rules wizard validates what you enter against that sport rather than against a basketball template. Rosters go in by paste or CSV and are previewed row by row before anything is written. A new coordinator can create a sample league in one click and see the shape of the thing before the real intake lands.
Multi-sport organization — 23 sports under one roof
One organization can run leagues in any of the 23 sports the platform supports, each with its own rules and scoring model, and there is no cap on how many sports an organization enables.
A sport must be explicitly enabled for the organization before leagues of that sport can be created — league creation returns 403 without a TenantSport row (backend/src/handlers/leagueHandler.ts:564). The Settings → Sports screen only adds sports; disabling is not exposed there and the API has no disable or delete endpoint, so a sport stays enabled once turned on.
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.
Sport-specific league rules validated per sport
League rules such as period structure and game length are entered once in a common wizard, translated into the chosen sport's own vocabulary, and validated against that sport's rule schema so an invalid setting is rejected at creation rather than at scoring time.
Unknown rule keys are stripped rather than rejected. Leagues created without an explicit sport default to basketball.
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.
Guided onboarding with a one-click sample league
A new operator can create a sample league with teams and a match in one click to see the product working before setting up their real season.
Available to signed-in operator accounts.
The gym is the constraint, not the fixture list
Name the venue, name each court, then generate a round-robin capped so each team plays a set number of games instead of a full rotation. That generation is free. Conflict windows are sized from the sport being played rather than a flat hour, so a short futsal slot is not treated like a two-hour final, and a venue already hosting at that time is raised before the fixture saves. The one-day tournament layout — slot spacing and a lunch break — sits inside the paid Tournaments module, alongside pools and bracket generation.
Venue management
Keep a venue list per league with a structured address, capacity, amenities and notes, and attach a venue to any match.
Venue details are operator-facing and require tenant read access to list (backend/src/handlers/venuesHandler.ts:238).
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.
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.
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.
Sport-aware match length for conflict windows
Conflict detection sizes the slot from the sport being played — derived from the league's period or half length where the sport has a clock, and from the sport's own declared typical match length where it does not — plus a handover buffer, with a per-league slot-length override that wins over both.
Match end times are not stored; the check assumes the proposed match and the existing one occupy the same length block.
Venue double-booking detection
Scheduling a match into a window when the same venue is already hosting one raises a conflict that must be acknowledged before it saves.
The server-side check compares the venue only — it does not consider which court within the venue, so two matches on different courts of the same venue are reported as a venue conflict. Court-level precision exists only as an advisory banner in the browser's schedule manager (frontend/src/pages/app/schedulerConflicts.ts:78), which never blocks a save.
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.
Anyone can keep the book
The volunteer signs in on a tablet, opens the game queue, taps their match, and gets the console for that sport — the volleyball one counts sets, the bowling one counts frames, and neither is a generic add-points box. A wrong tap is undone from the play-by-play. If the gym Wi-Fi drops while the console is open, the taps land in a queue on the device and are sent in order when it comes back. The console installs to the tablet's home screen and runs full-screen. Ending the game closes the book: the server refuses further scoring events on that match.
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).
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.
Sport-specific one-tap event capture
Each sport declares its own event catalogue — soccer records goals, own goals, cards, corners, offsides and shootout kicks — and the server rejects any event type the match's sport does not recognise.
Which events exist, and how many, differs per sport; there is no single universal event set. Event capture is refused when the league's LIVE_SCORING module is switched off (backend/src/handlers/matchHandler.ts:900).
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.
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.
Installable tablet/phone console (PWA)
The scorekeeper console installs to a tablet or phone home screen and runs full-screen without browser chrome.
The dedicated phone-first render tree with screen wake-lock exists for the basketball console only (frontend/src/pages/app/ScorekeeperMobilePage.tsx:170); other sports use their pack console's responsive layout. Installation is a web app install from the browser, not an app-store download.
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.
Get people to actually turn up
About a day before a match, every member of both teams who has claimed an account is reminded automatically — in-app, by push and by email — on a job that runs without an admin pressing anything. Coaches post announcements to their team. Fans following the league get the go-live and the final. The league-wide campaign composer, for one message to everyone or to a single team, is a setup-required surface: it needs the Notifications module on for that league and connected provider credentials, and its SMS channel is dormant today.
Pre-game reminders (~24 hours before a match)
Roughly a day before a scheduled match, every member of both teams is automatically reminded in-app, by web push and by email, on a scheduled job that runs continuously without an admin having to press anything.
Recipients are team members with claimed accounts, not league followers or ticket holders. Each match is reminded once, deduplicated. The sweep processes at most 200 matches per run (backend/src/services/pregameReminderService.ts:15) and honours the user's gameReminders toggle, so anyone who has switched that off receives nothing.
Team announcements
A coach or team captain can post an announcement to their team; it appears on the coach and player dashboards and fans out to each member's in-app inbox, web push, and email.
Editing, pinning and deleting an announcement are coach-only — a captain can post but not modify history (backend/src/handlers/teamFeedHandler.ts:1). Recipients must have a claimed user account; unclaimed roster entries are skipped.
Match-day alerts to league followers (live, score, period end, final)
Fans who follow a league receive match alerts — match going live, scoring plays, period end and final score — in their inbox and as web push, driven straight off the scoring write path.
Two opt-in layers apply: the per-league follow toggles (notifyOnLive / notifyOnFinal, both default on at first follow) and the user's global per-type preference. Email is sent for the FINAL milestone only — live, score-change and period-end beats are inbox and push only (backend/src/services/notificationService.ts:365).
In-app notification centre (bell + inbox)
Every user gets an in-app notification inbox with a bell in the top bar, a full notifications page, and mark-one / mark-all-read controls.
The bell is a client-side poll of GET /notifications (routes/index.ts:2096 documents it as the bell-icon poll), not a server-pushed stream. The list returns the user's most recent notifications, not unlimited history.
Player and coach invitations by email plus league join codes
Admins can invite people to a league or team by email, revoke or regenerate a pending invite, and share a short league join code that people can redeem to join; invitation links expire after 14 days.
Invite emails are delivered through a third-party transactional email provider configured per environment; the invite row is still created if the email fails to send. Join codes can be rotated or set to a custom value by an admin.
League mass-communication campaigns (email, SMS, in-app)
Needs live Twilio credentialsA league admin can compose one message to a chosen audience — everyone, admins, followers, or a specific team — pick email and in-app channels, preview the recipient count before sending, and see per-campaign sent / skipped / failed counts afterwards.
League-admin (or platform-root) only, and gated on the league's NOTIFICATIONS feature module, which 403s the whole surface when switched off (backend/src/handlers/messagingHandler.ts:129). The SMS channel is DORMANT in production — the Twilio secret still holds placeholder values, so an admin can pick SMS but every SMS recipient is recorded as skipped with reason not-configured rather than texted (backend/src/services/smsService.ts:29).
Somewhere to send them
Flip a league public and it gets its own page with the schedule and a table that builds itself from finalised results, readable in any browser with no account and nothing to install. A programme you would rather not list publicly can stay off the directory and hand out its join code instead — a signed-in fan who redeems it reads the same pages. Youth leagues can hide player names from the public pages with one setting, and anyone who wants the fixtures in their own calendar can subscribe to them.
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 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.
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).
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.
Roster-name privacy control for youth leagues
A league admin can hide player names from the public fan pages with a single setting, and the public API then omits them.
A per-league toggle; it is not a per-player consent flow and it does not retroactively remove names from anything already cached by a third party.
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.
Before you ask
Our scorekeeper is a parent who has never seen it. What do they actually have to do?
Sign in on a tablet, open the game queue, tap their game, and tap what happens. The console is picked from the match's sport, so nobody chooses a template or configures anything. A wrong tap is undone from the play-by-play log. Two things to plan for: someone has to grant them the scorekeeper role before they can open a console, and there is no per-match scorekeeper assignment — every scorekeeper in the league sees every game on the queue.
Half our players are drop-ins. Can we still add someone to a roster in week three?
Yes. A coach or league admin adds a player to a team roster at any point in the intake, with the jersey number checked for clashes and the position checked against that sport. Two things to know. A match can be set to lock its roster, which freezes edits for the 24 hours before it until an admin unlocks the match. And a walk-up who never lands on a roster can still play — the box score simply has nobody to attribute the points to. If the intake has a capacity limit, the waitlist promotes the next person waiting when a place frees.
Do drop-in players and parents need accounts?
Not to read anything. The public league page, its schedule and its standings open in any browser with no account and no app download. Registration is the same — a parent opens the shared link and signs a child up without making an account first. An account is only needed to follow a league, receive notifications, or see a personal registration history across programmes.
Volleyball finishes Thursday and basketball starts Monday. What do we have to set up again?
The sports themselves are enabled once for the organisation, so the basketball intake is a new league rather than a new configuration. Its rules go through the same wizard and are validated against basketball, the scorekeeper console changes on its own, and the standings columns follow the sport. The part that does not travel is the building: venues and their named courts belong to a league, so the new intake gets its own copy of the gym and its four courts. Name them once at the start of the intake and fixtures can be assigned to them from there.
A six-week intake leaves us about a week to get it live. What is the shortest path?
Create the league in the sport you are running, paste or upload the roster and check the row-by-row preview, generate a round-robin capped at the number of games each team really gets, then flip the league public and share the one link. If you want the sign-ups online too, the Registrations module has to be switched on for that league first and the form's open/close window has to be current — outside it the public page shows as closed rather than taking anyone.
The coordinator who set up the last intake has left. Do we lose the season?
No. Access is granted per person by invitation and scoped — the whole organisation, one league, or a set of sports — so the next coordinator is invited into the same leagues rather than handed somebody's password, and removing the leaver takes nothing with them. One gap worth knowing up front: league and team changes do emit audit events, but reading that log is a platform-operator surface today, not something an organisation admin can open for themselves.