The community’s app
- Volleyball, football and poolLISTS, PAYMENT, WAITING LIST, BBQ
- Your area: games, points, friends21 POINTS = A FREE SUNDAY
- The organiser’s consoleTODAY, MONEY, PEOPLE, TOURNAMENTS
A beach sports community Rikki started with four people. We built the two sites that run its week.

Sunday ran on a list pasted into the WhatsApp group. Keeping it straight took the organiser’s whole week.
Names deleted, two copies going round, someone pasting an old one.
NOW · ONE LIST; YOU ADD OR REMOVE ONLY YOUR OWN NAMEA name on the list was no promise of a payment.
NOW · A PLACE IS YOURS ONCE IT IS PAID; IF NOT, THE NEXT IN LINE GETS ITThe organiser spent the week asking for comprovantes, the Pix receipts.
NOW · THE RECEIPT GOES IN WITH THE PAYMENT; THE TICK APPEARS ON ITS OWN
Real screens from nomadfloripa.com in its demo mode, so every name is invented. Pick a step.
Full? The next in line moves up on their own
Monthly players first, then the place opens
Balance in one tap
Only what needs the organiser
What each game made
The bracket builds itselfSign up for the game, training and the BBQ in one go, pay by Pix and add the receipt. When a place frees up, the next in line is offered it without anyone lifting a finger.
Wednesday at 21:00. Monthly players confirm first; a place they leave opens to pay-per-play and the waiting list, and a kick-off alarm reaches every phone that asked for one.
Teams balanced from players’ ratings of each other and their positions, in one tap instead of twenty minutes of arguing.
One screen each morning: receipts to approve, who still owes, a court to ask the arena for. Everything else is already on track.
One transfer can’t pay twice. Paid in, paid out, what each game made and who still owes, worked out from the lists themselves.
Captains enter a team with an invite link. Pools and the schedule come out of one engine, players report scores from the sand on nomadvolleyball.com, and the bracket builds itself from the last pool score.
Every five minutes the server reads every open list and does what is due.
Both sites live in one repository and share one sign-in, one database and one set of server functions.


One organiser runs it on a Sunday morning. Each choice keeps that possible.
| Choice | WHAT WE CHOSE | WHAT IT COST LATER |
|---|---|---|
| 01One stack for both sitesSEPTEMBER 2026AstroCloudflareFirebase | Two sites in one repository, with one sign-in and one database: Astro on Cloudflare, Firebase behind. Next.js meant a full rewrite, and a server of our own meant operations and data-protection duties resting on one person. | Sign-in broke on iPhones. The sign-in helper lived on another domain, and Safari walls that storage off. It now passes through each site’s own Cloudflare worker. |
| 02Receipts, not a payment providerSEPTEMBER 2026Pix | Members pay by Pix and upload the receipt. Automatic payment waits on a question for the accountant, so for now the system checks receipts. | One transfer once arrived as three claims with the same screenshot. Each file’s fingerprint is now stored, so one receipt cannot pay twice. A cropped copy gets a new fingerprint, so the organiser still looks at every receipt. |
| 03One job every five minutesSEPTEMBER 2026 | One scheduled job reads every open list and does what is due: offers, deadlines, alarms. It began on Wednesday evenings only and grew to every five minutes, every day. | Each run reads every open list in full, and nothing happens more exactly than five minutes. |
| 04The server holds the rulesSEPTEMBER 2026Firebase | Anything that touches money or a place on a list runs in a server function, never in the browser. There are 28. | On 26 September a release of 34 functions hit the region’s processor limit and 11 failed to deploy. Each function now takes half a processor and one request at a time, so a busy tournament day can queue. |
Three real pieces, plain words first and then the code. Demo names; keys and tokens left out.
Both sites send what people do to a single Cloud Function, a few events at a time. The browser posts plain text with sendBeacon, so a batch still leaves when the page closes.
There is no API key in the request: visitors send too, so there is no account to check. Instead the function checks where the request came from, its shape, its size and how often that device sends. A signed-in member’s batch also carries their Firebase ID token, which the server verifies; the user id written in the body is never trusted.
POST http://127.0.0.1:5001/demo-nomad/southamerica-east1/log 1
// live: https://southamerica-east1-nomad-community.cloudfunctions.net/log
Content-Type: text/plain;charset=UTF-8
{
"v": 1,
"sid": "66f1…8334", "did": "37d8…b3b3", 2
"app": "floripa", "n": 1, "demo": true,
"uid": null, "admin": false, // a member's batch adds "token"
"meta": { "ua": "Chrome · Mac · desktop", "tz": "America/Sao_Paulo", … },
"events": [ 3
{ "t": "page", "p": "/volleyball?demo=1", "conn": "4g" },
{ "t": "screen", "s": "volleyball" },
{ "t": "perf", "ttfb": 7, "lcp": 56, "load": 74 },
{ "t": "click", "el": { "tag": "a", "text": "Football", "href": "/football" } }
]
}access-control-allow-origin: https://nomadfloripa.com, POST only, cached for a dayEvery week’s game is made from a template: Rikki’s real WhatsApp list, turned into data. The sport, the venue and the day are the categories. The court place, the training and the BBQ are the items a player signs up for, and each has its own price for a regular, a newer player or a guest.
A sign-up is one record per part, so a court place, a training and a BBQ are paid, waited for and cancelled separately. Each record is in one of four states: claimed, confirmed, unpaid or cancelled.
export const VOLLEYBALL_SUNDAY = {
key: 'volleyball-sunday',
sport: 'volleyball', venue: 'Arena Beach', 1
day: 'Sunday', time: '11:00 – 13:00',
courts: 3, courtSize: COURT_SIZE, // 4 v 4, every court filled to 8
prices: { 2
court: { legend: 27, member: 30, guest: 32 }, 3
clinic: { legend: 15, member: 20, guest: 20 },
bbq: { legend: 45, member: 50, guest: 50 },
},
clinic: { capacity: 10, courts: 1, level: 'intermediate' },
bbq: { min: BBQ_MIN }, // on only once ten sign up
};The log records the address, the screen in view and every tap: what a button says, never what anyone types. It also catches the trouble: script errors, a request that failed or took over four seconds, and frustration read from the taps, such as three taps in 600 ms on one spot, or a tap that changed nothing.
Nine goals mark the moments that count, each with a fixed id in the code, never read off a button’s words. Google Analytics hears them only after a visitor says yes. Batches are stored by the session for 13 months; every night they are summed into counts that are kept. Only the organiser’s console reads them, on its Logs and Growth pages; the public pages show counts, never names.
export const GOALS = { 1
whatsapp_opened: 'group', // a WhatsApp group link tapped
signup_started: 'intent',
account_created: 'method', // password · google
profile_completed: 'sports',
place_taken: 'sport',
paid: 'sport', // a comprovante or credit
share_tapped: 'what',
invite_copied: 'sport',
referral: 'sport',
};
export function goal(id, props = {}, win = globalThis.window) {
if (!Object.hasOwn(GOALS, id)) return null; 2
…
}Pages, screens, taps, sheets, errors, slow or failed requests, frustration, goals
sessions/{sid}, its batches/{n} and issues/{key}; 13 months, then counts only
Anything typed, emails, phone numbers, passwords, IP addresses
One found in the browser’s network timings, one by reading what a public record held.
Rikki, on the site saved to his iPhone’s Home Screen: “when i click on events … it freezes and wont change”. The address changed; the screen didn’t.
Reproduced in Apple’s WebKit engine and each read timed: the database’s streaming connection stalled there, and a read hung 6 to 30 seconds at random, against about 50 ms in Chrome. The cache made no difference, so the fault was the connection. The fix switches Apple’s WebKit to long polling and leaves every other browser alone: every read then answered in 0.1 to 0.9 seconds.
Found on the way: the site’s own log had dropped the opening batches of every page after the first, because each page counted from 1 again and the server skips a number it already has. That is why the freezes left no trace. The count now runs across the whole visit.
export const isAppleWebKit = (nav = globalThis.navigator) =>
/^Apple/.test(String(nav?.vendor || '')); 1
export const firestoreSettings = (F, nav) => (isAppleWebKit(nav)
? { localCache: F.memoryLocalCache(), experimentalForceLongPolling: true } 2
: { localCache: F.persistentLocalCache({ 3
tabManager: F.persistentMultipleTabManager() }) });6–30 s a read, at random
0.1–0.9 s every read
Making the football teams saved every player’s rating on the game, and anyone can read a game, visitors included. The privacy page promised that only the organiser sees ratings. Nothing live had leaked: the one night on the site held no rating when checked.
The fix is one mapping, run before a game is saved: copy each team without its rating total, and each player without their rating. The ratings now sit in a separate record kept for that night’s players.
rating out; keep the rest as t.export const publicTeams = (teams) => (teams || [])
.map(({ rating: _sum, ...t }) => ({ ...t, 1
players: (t.players || []).map(({ rating: _r, ...p }) => p) })); 2// balanceTeams(…).teams[1] 3
{ "name": "Team B", "rating": 15, "players": [
{ "name": "Yuki R.", "lane": "midfield", "rating": 3 },
{ "name": "Emma S.", "lane": "defence", "rating": 3 }, … ] }
// publicTeams(…)[1], what the game keeps
{ "name": "Team B", "players": [
{ "name": "Yuki R.", "lane": "midfield" },
{ "name": "Emma S.", "lane": "defence" }, … ] }Every figure is from the community’s own records; the source sits under each one.
Rikki founded Nomad Volleyball and still organises it. ArtiFxn Design built both sites, with Rikki directing our AI agents.
The site ships near-daily, and many changes start with the players. After each Sunday they leave notes; a member survey wrote the tournament rules. What members say becomes the next release.
50% of people showed up late.BUILT · THE SUNDAY MORNING EMAIL, 1 NOV 2026
A ‘Could improve’ section to send to the players privately.BUILT · ONE PRIVATE NOTE, ONLY THE PLAYER SEES IT
A BBQ called off for low turnout after people had paid.
BUILT · PLEDGES FIRST, MONEY ONLY AT TENThe system grows the way it started: one problem from the group at a time.
Its own optional BBQ at its own price, run by the same rules as the Sunday one.
WhatsApp messages sent one to one through the official business API, not pasted by hand.
The public pages already switch to Brazilian Portuguese; the organiser’s console and the emails come next.
NEXT CASE KNG Estate and GOV Roleplay
Read the caseWhether you are hiring or building, the first step is the same: one conversation with Rikki Chandler.
Rikki connects customers to systems, fixes what breaks and explains it so it sticks.
Tell us the one thing that should change. We start from that question and a written test of how we will know.
Every door leads to the same person.