For most of its life, Atmos Football was a closed garden. You only got in if someone sent you a share code. Once inside, the experience was smooth — automated leaderboards, fair team generation, and proper stats. But the groups were completely isolated from each other.

This created a growth problem. Organisers wanted an easier way to attract new players. New players wanted to browse nearby groups and get a feel for the standard before showing up to a random Wednesday night game in a park. WhatsApp spam and Reddit threads weren't scaling.

So we built Group Discovery — a way for players to find nearby groups, evaluate them, and request to join. The organiser reviews the request and approves (or rejects). The new player appears in the group automatically.

The feature shipped across three releases (GD-1a, 1b, and 1c). What started as a simple "find nearby groups" idea turned into one of the most interesting architectural challenges we've tackled. Here's how we did it.

1. The Problem That Doesn't Look Like a Problem

Inside an existing group everything works well. Share code → join → play.

The discovery problem is more subtle. New players have no good way to evaluate a group before committing time and travel. Organisers have no lightweight way to signal "we're looking for players" to people who aren't already in their WhatsApp group.

We needed a discovery surface that was:

  • Public enough to be found
  • Private enough to protect everyone's data
  • Lightweight enough that small groups wouldn't be overwhelmed

The final design uses three layers of explicit opt-ins and careful data modelling.

2. GD-1a — Rating Snapshots: Cheap Reads for Expensive Calculations

The first big technical challenge was performance and cost.

Elo ratings are calculated from the full game history with multiple convergence passes. This is fine when saving a single game, but expensive if you recompute it every time someone browses nearby groups.

Solution: A denormalised ratingSnapshots/current document per group.

After every game save (add, edit, or delete), the same client-side code path that writes the game also writes the snapshot — writeRatingSnapshot() runs as part of the save flow, no Cloud Function trigger needed. It stores per-player data (current Elo, rank, percentile, win rate, games played, confidence tier) plus group-level stats like activePlayerCount (unique players in the last 10 games) and avgGameSize.

This snapshot is publicly readable but only writable by group organisers.

The impact is massive: a discovery search showing 15 nearby groups now costs 15 reads instead of potentially hundreds of game-document reads. We also learned an early lesson — we initially showed totalPlayers (everyone who ever played) instead of activePlayerCount. A 5-year-old group with 200 lifetime players but only 10 regulars looked misleadingly large. We fixed it quickly.

A backfill tool in admin settings lets us rebuild snapshots on demand.

3. GD-1b — The Geosearch and Join Requests

With cheap group metadata available, we could build the actual discovery layer.

We created a separate discoveryListings collection. A group only appears in searches if the organiser explicitly opts in and creates a listing. This contains public marketing data: name, description, pitch address, day/time, typical player count, skill level, and the embedded rating snapshot.

Geosearch: Because the number of public groups is still small (hundreds, not thousands), we do a client-side search. The app fetches all public listings (one read) and runs a Haversine distance calculation in the browser. For our scale this is simpler and cheaper than maintaining a geohash index or GeoFirestore.

Organisers can enter an address and use a "Set GPS from address" button powered by Nominatim (OpenStreetMap) for geocoding.

Join Requests are deliberately privacy-focused:

  • A signed-in player can send a request containing displayName, an optional message, and uid — but never the email.
  • The organiser sees the request with name and message only.
  • Approval triggers a Cloud Function (onJoinRequestApproved) that automatically adds the user to the group. No share code required.

A mirror copy is kept in the user's own joinRequests subcollection so they can track their requests.

4. The Auto-Join Cloud Function

Early user testing showed that "organiser approves → player manually enters share code" caused confusion and drop-offs. So we added a small but powerful Cloud Function.

When a join request status changes to approved, the function uses the Firebase Admin SDK to add the groupId to users/{uid}.groups. The group appears for the user on their next refresh.

The function is small, idempotent, and runs in europe-west2 to avoid cross-region costs.

5. GD-1c — Portable Rating Cards

The final piece was cross-group trust and identity.

Players can publish an optional public profile (users/{uid}/publicProfile/default). They choose which groups' ratings they want to showcase.

When an organiser reviews a join request, they can see the applicant's portable rating card — rank, percentile, win rate, etc. — pulled live from the relevant group's rating snapshot. This gives a real signal of skill level without exposing private data.

Privacy is layered and opt-in:

  • Public profile is off by default.
  • visibleGroups is a per-group toggle.
  • Only groups where the player has a linked identity can be shown.
  • Strict Firestore rules with field allowlists and size caps protect the public document.

6. The "Top X%" Bug Worth Telling

During rollout we hit a classic maths bug (BF-47d). Portable cards were showing every #1 player as "Top 1%", even in small 10-player groups.

The original formula was too simplistic. We fixed it to Math.ceil(rank / total * 100). A good reminder that percentile calculations are an off-by-one minefield — we now include a 10-player test group in QA specifically to catch these.

7. What I'd Do Differently

A few honest reflections:

  • Test with a small group from day one. A 10-player test pool would have caught both the totalPlayers vs activePlayerCount issue (BF-47c) and the "Top 1%" bug (BF-47d) before they reached real users.
  • Build the per-group visibility toggles for portable cards in the same release as the profile feature. Players who try the profile once and find it exposes more than expected don't always come back to refine it.
  • Remove email storage from join requests from day one. v2.7.2 retroactively cleaned this up, but the original schema stored it. Better not to have collected it in the first place.
  • The Cloud Function for auto-join was one of the best decisions — invisible to users but dramatically improved the experience.

The biggest architectural win was the layered opt-in model. Every public surface requires explicit consent. That philosophy shaped every rule and UI decision.

The Final Architecture

We ended up with three carefully controlled public surfaces:

  • ratingSnapshots/current — public read, organiser write
  • discoveryListings — public read, group opt-in
  • users/{uid}/publicProfile/default — public read, player opt-in

Plus a Cloud Function that turns approval into membership, and strict private silos for everything else.

Group Discovery is now live. Players can find nearby groups with confidence. Organisers can grow thoughtfully. And privacy remains protected at every layer.

If you run a regular football group and want more players, you can enable discovery in Group Settings → Discovery Listing.

You can try the feature in the Launch Demo (location access is simulated there). For the longer journey of the app, see The Start.

— James