Skip to content
jaysoncdev.com
Contact
All projects

Case study

AFTL Raid Manager

A guild was running its entire weekly operation out of a shared Google Sheet: who's raiding, who's geared, who owes whom for loot. I replaced it with a web app and kept building on it, eventually well past what the sheet did — including a live tournament mode with drafting, spectating, and betting.

Role
Solo developer — design, frontend, database, serverless backend
Timeline
2025 – present
Status
Live, actively maintained

The problem

Coordinating a guild raid means tracking who is available, what gear each of their characters has, which eight-player lineups are viable, and who is owed what after loot drops. All of it lived in one Google Sheet that several people edited simultaneously, with no validation and no history. Lineups broke silently, loot debts got forgotten, and only one person understood the sheet.

What I built

  • A single-page app with a hand-rolled router and no UI framework — ten pages covering lineups, the player pool, a drag-and-drop lineup editor, gear enhancement planning, recruiting, and an admin console.
  • A Postgres schema on Supabase behind a Discord OAuth login, with app-level roles deciding who edits lineups and who just views them.
  • Discord OAuth as the only login: the guild already lives in Discord, so guild identity is the account.
  • A loot ledger where players upload a screenshot and Claude's vision API extracts the items into structured rows, instead of someone typing them in by hand.
  • An arena mode: a real-time PvP tournament with character drafting, turn-based match resolution, live spectating, and a gold betting market, all synced through Supabase Realtime.

Screenshots

The raid lineups page: eight character cards with gear levels and availability windows, a physical and magic balance meter, and a row of saved lineups below
The weekly view — active eight-player lineups with gear, availability windows, and clear status
The lineup editor: numbered drop slots, a guest character slot, live physical/magic balance meters, and a filterable pool of available characters
The drag-and-drop lineup editor, with live damage-balance meters and an availability-filtered character pool
The loot log: cards for each cleared raid showing items, gold value per member, and per-player claim status
The loot ledger — per-raid drops, automatic gold splits, and claim tracking
The character browser: a filterable grid of 168 characters with class icons, gear score, and per-raid completion checkmarks
The character browser — 168 characters, filterable by class, with weekly completion tracking
The roster view grouped by owner, expanded to show one account's characters with full armor, weapon, accessory, and stat breakdowns
Roster view grouped by account, down to per-slot gear and stats

Technical notes

Swapping the backend under a live app

The app originally read and wrote through the Google Sheets API. Moving to Postgres meant changing the data layer under a live app, so I built the Supabase client as a drop-in alongside the Sheets client behind a shared interface, ran both during cutover, and kept the Sheets path as a fallback until the new schema had proven itself under real traffic.

No framework, on purpose

The app is plain ES modules — a small router, explicit render functions, and direct DOM updates. It started as a learning constraint and stayed because the payload is tiny and there's nothing between me and the bug. It also meant that when I picked React back up, the problems it solves were ones I'd already run into by hand.

Keeping game logic server-side

Arena actions, draft picks, bet resolution, gold payouts, and the Discord OAuth token exchange all run in Netlify Functions with the service role key — one authoritative implementation of the game rules instead of logic scattered across whichever client submits first, and the OAuth client secret never reaches the browser.

Real-time multiplayer state

Arena matches have several people watching one authoritative game state at once. Supabase Realtime pushes each state transition to spectators, with server-side force-actions so an admin can unstick a match when someone disconnects mid-draft.

By the numbers

246
Commits
16
Screens
16
Serverless functions
53
Schema migrations
42k
Lines of app code

Stack

Frontend

Vanilla JavaScript (ES modules)ViteSCSSChart.jsFlatpickr

Backend

Supabase (PostgreSQL)Row-level securitySupabase RealtimeNetlify Functions

Integrations

Discord OAuth 2.0Claude API (vision)Google Sheets API (legacy)

What I'd do differently

  • Fifty-three loose SQL migration files works until it doesn't. I'd use a proper migration tool with up/down pairs from day one.
  • Several page modules grew past a thousand lines before I split them. I now break a screen apart the moment it has two unrelated reasons to change.
  • Building it framework-free was the right call for learning and the wrong call for the arena — shared reactive state is exactly the problem React exists to solve, and I hand-rolled a worse version of it.