A CS2 Major Pick'Em companion for a small private group. Members log in with Steam (or play locally — no Steam needed), set/mirror their picks, and compete on a shared leaderboard scored on Valve's own weighting. Built generically so it can be re-pointed at future Majors.
First season, IEM Cologne 2026, is complete and archived. The next Major, PGL Singapore 2026, is already seeded in the event registry and will stand up automatically when it comes around.
Next.js (App Router) + TypeScript + Prisma/SQLite. Deploys as a single container on the
phattvip network behind SWAG at pickems.phatt.vip. The responsive installable PWA is the
entire mobile story (no native apps).
📚 Knowledge base →
docs/— architecture, the next-Major runbook, operations, and hard-won gotchas. Start there before changing the system or standing up a new Major. Keep it true: update the relevant doc in the same PR. If you're picking this up cold — see docs/CONTINUITY.md first.🔒 Authorship gate — every commit on
phatt-picksmust be authored byphattbeatswith zeroCo-authored-bytrailers. Seedocs/AUTHORSHIP.md. Enforced locally by acommit-msghook (installed bynpm install) and in CI by.github/workflows/authorship-check.yml.
- Per-stage picks — Swiss stages as 2 / 6 / 2 buckets, plus one interactive QF→SF→GF playoff bracket you place at once (tap a winner, they advance).
- Steam mirror — pull your live Valve Pick'Em and (optionally) push locally-set picks back up.
- Shared leaderboard — scored on Valve's own weighting; picks hidden until each stage locks.
- Stage Reveal & Wrapped — per-stage reveal boards and a click-through "Stage Wrapped" recap.
- The Bleachers — drop a fixed reaction stamp on someone's revealed pick; anonymous in the moment, unmasked when the stage resolves.
- Playoff Spotlight — per-team narrative + highlight, with live Polymarket implied odds.
- Challenge coins — a tiered collectible minted per Major you played, once the Grand Final crowns a champion.
- Notifications inbox — an in-app bell for reactions, reveals, and other activity, alongside push.
- PWA + Web Push — installable, with opt-in 24h/1h pre-lock reminders.
- Multi-Major by design — an event registry + clock-derived lifecycle re-points the app at the next Major from committed config (PGL Singapore 2026 is already seeded).
NODE_ENV=development npm install --include=dev --cache "$(pwd)/.npm-cache"
DATABASE_URL="file:./dev.db" npx prisma generate
DATABASE_URL="file:./dev.db" npx prisma db push
npm run dev # http://localhost:3000A gitignored dev .env (SQLite + dummy secrets) is already in the repo root.
See .env.example. Summary:
| Var | Purpose |
|---|---|
DATABASE_URL |
SQLite path — use an absolute appdata path in prod (not a FUSE share). |
NEXTAUTH_URL |
Public base URL (https://pickems.phatt.vip). Used for OpenID return + invite links. |
NEXTAUTH_SECRET |
Session-JWT signing secret. openssl rand -base64 32. |
STEAM_API_KEY |
One app key, server-side only, never sent to the client. |
AUTH_CODE_ENCRYPTION_KEY |
32-byte hex; encrypts per-user Valve auth codes at rest. |
WRITE_ENABLED |
"true" enables the stage-batched write-back to Valve (destructive — overwrites the owner's live picks). Defaults false; leave off for normal play, flip on only for deploy-smoke or a deliberate write-back. |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
Web Push keypair. Generate: npx web-push generate-vapid-keys. |
VAPID_SUBJECT |
mailto: contact for push. |
PRELOCK_REMINDERS_DISABLED |
(optional) 1/true turns OFF the in-process pre-lock reminder scheduler. It is ON by default with no env required. |
STAGE_LOCKS_JSON |
(optional) override for the reminder cutoffs; defaults to the committed COLOGNE_LOCK_SCHEDULE when unset. |
HOTLINE is an installable PWA (public/manifest.json + public/sw.js). Installing is
required for push on iOS — opt-in pre-lock reminders (a 24-hour and a 1-hour warning before
each stage locks) are delivered via Web Push.
- iPhone: tap Share → Add to Home Screen, then open HOTLINE from the new icon. iOS only delivers Web Push to an installed, home-screen-launched PWA — this step is mandatory there.
- Android / desktop: use the browser's "Install app" / "Add to Home screen" option. Push works without installing, but installing is recommended.
Enable reminders under You → Pick-lock reminders, then Send a test reminder to confirm.
The real 24h/1h reminders are fired by an in-process scheduler (src/instrumentation.ts, a
~5-min tick) — no external cron/sidecar. The scheduler is ON by default; set
PRELOCK_REMINDERS_DISABLED=1 to turn it off. Cutoffs come from the committed
COLOGNE_LOCK_SCHEDULE; STAGE_LOCKS_JSON is an optional override. Pure scheduling logic
is in src/lib/prelock-reminders.ts and src/lib/notify-core.ts.
Every player has a stable invite link (/join/<code>). Share it from You → Invite friends. The
landing page onboards a friend in one tap — local play needs no Steam account.
Images are published to ghcr.io/phattbeats/phatt-picks. Pull :latest to track main, or pin to
a release tag for rollback:
docker pull ghcr.io/phattbeats/phatt-picks:latest # always-current main
docker pull ghcr.io/phattbeats/phatt-picks:vX.Y.Z # pinnable release (e.g. v0.1.0)
docker pull ghcr.io/phattbeats/phatt-picks:X.Y # latest patch in that minor lineCutting a release = push a vX.Y.Z git tag; CI publishes :vX.Y.Z, :X.Y, and updates :latest.
Build the container to join phattvip with the DB on a non-FUSE appdata path. SWAG proxy-conf the
owner installs:
# swag: pickems.subdomain.conf
server {
listen 443 ssl;
server_name pickems.*;
include /config/nginx/ssl.conf;
location / {
include /config/nginx/proxy.conf;
resolver 127.0.0.11 valid=30s;
set $upstream_app major-companion; # must match the container name on phattvip
set $upstream_port 3000;
proxy_pass http://$upstream_app:$upstream_port;
}
}POST /api/outcomes/ingest is event-gated, not poll-driven. It returns
source: "none", reason: "no-locked-unresolved" whenever the live layout has
zero locked stages with missing StageOutcome rows — i.e. pre-event. A
scheduler/cron pointed at this route MUST honor that signal and back off until
stage locks roll over; in that state the route makes zero outbound calls to
Liquipedia. The persisted SourceState.lastCallAt enforces the 1-req-per-30s
Liquipedia min-interval across container restarts, so even a restart loop
cannot violate the API terms. Regression: node scripts/verify-outcomes-gate.ts.
Match results / outcomes: Liquipedia (CC-BY-SA 3.0). Team logos: ByMykel/CSGO-API, falling back to self-hosted slugs / monograms.