Files
hermes-skills/skills/devops/python-web-service-deployment/references/shark-game-fullstack-pattern.md
T

4.5 KiB

Shark Attack Fantasy Game — Full-Stack Pattern

Architecture

iOS Safari → Cloudflare → Caddy (shark.iamgmb.com) → FastAPI (port 8083)
                                                      ├── /api/* (auth, leagues, draft, scores)
                                                      └── /* (static frontend via FileResponse catch-all)

Domain / DNS setup

  • Domain: shark.iamgmb.com (Cloudflare zone f1fb2d357b8ff0fab54c5856130ec9ed)
  • Points to Core's IP (152.53.192.33) via A record (not CNAME — CNAME flattens fail for cross-account zones)
  • DNS-only initially, switch to proxied after TLS cert provisions
  • Caddy full-proxy pattern: EVERYTHING routes through the backend on 8083

Backend features learned

  • FastAPI monolithic server.py — ~980 lines, no routers, all endpoints in one file
  • SQLite with WAL mode + foreign keys
  • Auth: bcrypt password hashing, PyJWT tokens, 30-day expiry
  • JWT gotcha: datetime.utcnow() vs datetime.now(timezone.utc) — PyJWT expects naive UTC
  • Registration response must include display_name — frontend uses it immediately
  • Snake draft logic: round 1: 1→N, round 2: N→1, round 3: 1→N. Track via round number: odd rounds are forward, even are reverse
  • Scoring engine: events table + materialized user_scores table recalculated on each insert
  • Point values: sighting=2, bite=5, fatality=10

Draft regulation

  • Minimum 2 members to start a draft (server enforces)
  • Commissioner-only start draft endpoint
  • Turn enforcement by draft_order — server rejects out-of-turn picks with 403
  • Region locking — duplicate picks return 409

32 Regions with ISAF-based data

Top 5 regions by 10-year sighting count:

Region Sightings Bites Fatalities
Australia East 87 28 6
Florida East Coast 85 25 1
Hawaii 47 12 2
Australia West 42 15 8
Caribbean 40 10 2

Full data saved to /root/shark-game/references/shark-incident-data-10yr.md sourced from ISAF year-by-year reports.

Caddy full-proxy pattern

When the backend serves BOTH API and static files, use:

shark.iamgmb.com {
    reverse_proxy 127.0.0.1:8083
}

Backend catch-all route:

@app.api_route("/{path_name:path}", methods=["GET"])
async def serve_frontend(path_name: str):
    if path_name.startswith("api/"):
        return {"error": "not found"}
    file_path = os.path.join(STATIC_DIR, path_name)
    if os.path.isfile(file_path):
        return FileResponse(file_path)
    return FileResponse(os.path.join(STATIC_DIR, "index.html"))

Frontend-JWT integration pattern

  • Token stored in localStorage as shark_token, user as shark_user
  • Auth guard on each page: if (!getToken()) window.location.href = 'index.html';
  • API base is empty string (same-origin)
  • Registration sends: {email, password, display_name, phone?}
  • After auth, checks /api/leagues to determine next screen
  • Pages: index.html (landing/register/login/league-mgmt), draft-room.html (draft/leaderboard/shark-feed/learn), league.html (settings/invite)

Multi-tab draft room pattern

The draft room has 6 tabs:

  1. DRAFT — Map view (Leaflet/OSM) | List view toggle
  2. MY TEAM — User's drafted regions + cumulative score
  3. STANDINGS — Leaderboard (all players ranked) + daily breakdown
  4. 🦈 LEARN — Species guide, shark facts, safety tips, conservation
  5. LEAGUE — Invite code, member list, start draft button (commissioner)
  6. 🦈 SHARK FEED — Upcoming shark media (Shark Week, movies) + general shark news

AI scraper pattern

  • Daily cron: Firecrawl search → admin-ai classification → pending-scores.json
  • Region matching via keyword/fuzzy matching of article text/location
  • Dedup via processed-urls.json

Common pitfalls

  • Missing .hidden CSS class — New standalone pages often define .hidden via JS but forget the CSS rule .hidden { display: none !important; } in <style>. Index pages get it; sibling pages don't. Always add it to every new page.
  • Registration returns display_name: null — The /api/auth/register endpoint must pass display_name=req.display_name in the response, not just write it to the DB. Login endpoint does this correctly (reads from DB); register endpoint must be patched to match.
  • Cross-account DNS — A records work where CNAMEs silently fail for Cloudflare cross-account setups.
  • Serve both frontend and API from one backend — Full-proxy through FastAPI + FileResponse catch-all avoids Caddy routing conflicts.