The plan was three of us.

Three grapplers, one open-mat schedule, a recurring problem: who's actually showing up tonight? The duct-taped solution we'd been running — a WhatsApp group, a coach's memory, and a shared spreadsheet that nobody updated — worked the same way duct tape works on a leaking pipe. Right up until it doesn't.

So I sat down to build the smallest possible thing that would tell us, at a glance, who was on the mats this evening. Just RSVPs. Three names. One subdomain.

Thirty-seven days later, the repo has 436 commits, the worker has 26,695 lines of JavaScript in one file, and it serves a 141-route platform that does training logs, sparring challenges, a cross-club throwdown board, an editable platform admin, four tone systems for the copy, a curated 115-technique library, and a heritage-styled magazine landing page that I did not, at any point, sit down and plan to build.

It got away from me. This is the article about how.

The shape of the thing

It's a multi-tenant Cloudflare Workers platform. Each grappling club gets its own subdomain — yourgym.grapplers.club — and behind it, its own member list, its own training data, its own schedule, its own private home on the internet. Members RSVP, log every roll, build heatmap streaks, issue sparring challenges, and share techniques. None of it crosses the club line without explicit opt-in.

The whole product fits inside three Cloudflare primitives:

That's the whole stack. No database in the relational sense. No Redis. No queue.

What's not in the stack is the more interesting part:

The reason this works is the project doesn't need what a framework provides. Grapplers club is mostly forms and tables and a heatmap. The dynamic surface area is small — one canvas (the excuse roulette wheel), one heatmap interaction (click-a-day to see sessions), a few modals, a service worker for offline. None of that requires React, and writing it without React saved us from the maintenance trail React drags behind it.

Heritage Chronicler

The visual design is called Heritage Chronicler. It's the only part of the project that took longer than building features. Warm parchment cream, brown serif headlines (Noto Serif), uppercase eyebrow labels in 0.22em letterspacing, extending horizontal rules on section headers, a 4px border radius on everything that needs a border radius. It looks like a turn-of-the-century training journal, and that's the point — grappling is a sport with lineage, and the UI should know it.

There are five themes a club can pick from (darkbelt, midnight, ocean, chalk, tatami) but Heritage is the default direction.

There are also four tonesboomer, competitor, coach, neutral. Every piece of microcopy in the app comes through a tone.{key} resolver. A coach-tone club sees "Who's training today?" and "Great — see you on the mat!" A competitor-tone club sees "Training Schedule" and "RSVP confirmed." A boomer-tone club sees "Who's actually showing up?" and "You're in. Try to actually show up." Same database, same code, four personalities.

Nothing in the app is hardcoded in English-the-language. It's all tone.something. 181 keys × 4 voices = 724 strings that all have to land in the right place. Most of the development scar tissue lives in the tone system.

Privacy is the design constraint, not a feature

I work in cyber security for a living. The day job involves looking at what other people built and wondering, out loud, what they were thinking. Building something that grapplers — including the grapplers I train with — would actually trust meant the security model had to be load-bearing from day one, not shipped in a Q3 update.

A short list of what that looks like in practice:

Where it's honest about what's still soft: the HMAC secret signing every session cookie is a single env binding. If it leaks, every active session is forgeable until rotated, and rotating logs everyone out. I know. It's on the list.

Privacy at the data level: every group lives behind its own subdomain, in its own KV-key-prefixed namespace. Member email addresses don't appear in any user-facing HTML other than the member's own profile and an admin's own admin panel. The cross-club Throwdown board is opt-in per member, with three visibility tiers (nickname, first name, full name) and a radius slider — and the default is "most private" because that's what people who don't read the form actually want. The privacy page lives at the apex. The security.txt is reachable on every domain. The robots.txt on every group subdomain says Disallow: / so no club's private mat schedule ends up in a search index.

None of this is novel. All of it is undramatic. That's the point: security and privacy show up in this app as the absence of obvious mistakes, not as a marketing page.

What got cut

The thing I'm most proud of is the list of features we built halfway and then dropped:

Every one of these is documented in the project's CLAUDE.md under a section literally titled "Explicitly NOT shipped (DROPPED, not coming later)." Because someone — me, in a moment of weakness, or a future contributor — was going to ask. The list is there to say no for the third time.

The Claude Code angle

I built this with Claude Code sitting next to me the whole time. The 436 commits across 37 days look insane in isolation; in practice it's because Claude reads the repo, edits the file, runs the deploy, and watches the result, while I tell it what to build next and push back when it tries to add a feature flag I don't want.

A single recent session shipped seven commits to production: a dark-launched /about and /articles pair, optional invite emails on the admin panel, a refreshed landing-page screenshot, a sticky left section nav for /admin at ≥1280px, a cleanup that dropped 226 lines of unused message fields and a retired boomers-migration script, and — the one I'm happiest about — consolidating per-day training keys (training:{memberId}:{date}) into yearly buckets (training:year:{memberId}:{YYYY}), cutting the read volume on the heatmap from O(members × days) to O(members × ~2 years).

That last one is roughly a 100× reduction in KV reads for a 30-member club. It's the kind of refactor that pays for itself the moment a club has more than ~50 active members and starts to feel slow. It's the kind of refactor I would have deferred forever without a pair-programmer who could write the migration script, run it on staging, verify the result, and ship it before lunch.

What it costs

The whole platform runs inside Cloudflare's free tier. KV reads at our current volume don't cost anything. Workers requests don't either. R2 stores tens of avatars and a few logos. The only line item is the domain registration and Resend, which costs nothing until you start sending real volume.

Three grapplers can have a private home on the internet for the cost of a domain. Thirty grapplers, same. Three thousand, probably the same.

When it breaks

Two truths to start: software breaks, and Cloudflare's edge runs more nines than I do. Most of the reliability story is "we delegate to the platform," but the parts the project actually owns want their own treatment.

What's visible when something goes wrong:

What happens at the browser when the server is unreachable:

Where it is now

The repo has a public clubs directory, a per-club landing page for clubs that opt in, a join-by-code apex flow with a printable QR for the wall of the gym, a privacy page, a security.txt, a service worker, a 100/100 Lighthouse PWA score, two environments (prod and staging), and an enricher Python tool that hits the YouTube Data API to find videos for the platform technique library so every move ships with a thumbnail.

Three grapplers. One worker.js file. Twenty-six thousand lines.

I should probably have stopped at the WhatsApp group.