2026-04-02

XP and leveling system — shipped

Built and merged the full XP/leveling backend and initial frontend in two stacked PRs (#14, #15).

What shipped

Backend (PR #14):

  • BECMI Fighter XP table: levels 1–36, doubles through 1–8, linear 120k/level from 10 onward
  • New fields on Members: xp (cumulative, never decreases), level (auto-computed via beforeChange hook), class (relationship to Classes collection)
  • awardPoints now awards XP alongside points, returns level-up info for callers
  • Tier multipliers apply to purchase XP only — event/checkin/quest XP is flat (prevents pay-to-outlevel)
  • Classes collection replaces enum — adding/renaming classes is now a data change, no migration needed
  • 25 new unit tests for XP table, updated loyalty tests — 68 total passing
  • Seed includes XP backfill so test member starts at Level 4

Frontend (PR #15):

  • Dashboard redesigned as character sheet layout: portrait left, name/tier/level/XP bar hero section, stat cards demoted below
  • XP progress bar using shadcn Progress (Radix) with proper accessibility (aria-valuenow, clamped values)
  • Class name displayed from relationship

Design decisions made today

  • XP rate = points rate: $1 = 100 XP = 100 points. Same base, same multiplier rules. GDD confirmed this.
  • XP backfill at 1x: all historical purchases get retroactive XP at base rate regardless of current tier. GDD calls this the “powerful first moment” — regulars sign up and see Level 3+ immediately.
  • Classes renamed: fighter/magic-user/cleric/rogue → warrior/mage/healer/rogue. FF-inspired, universally recognizable. Descriptions are taglines not sentences: “Strength, steel, and glory”
  • Classes as collection, not enum: future expansion (subclasses, EQ-style tree) is a data change, not a migration. Worth the extra join.
  • Don’t modify auto-generated migrations: use migrate:fresh for local dev. Production runs once in order.

Staging deployment notes

The migration dance on staging was painful — dev mode pushes schema changes without recording them in the migrations table. Had to manually insert/delete migration records and drop columns via SQL. For future reference:

  • Check payload_migrations table before deploying
  • If dev mode already pushed columns, drop them + delete the stale migration record before deploying the new one
  • Or just wipe staging if there’s no real data

Copilot review — one real bug caught

Copilot found a real bug: the level beforeChange hook would reset level to 1 on any member update that didn’t include xp in the payload (e.g., subscription status change). Fixed by falling back to originalDoc.xp. Also created #16 for the pre-existing race condition on read-modify-write in awardPoints.

Created /gh skill

Global Claude Code skill for GitHub CLI operations — PRs, issues, review comment replies. Key gotcha documented: reply endpoint is pulls/comments/{id}/replies, not nested under the PR number.

/r/outside connection

Realized why the guild concept resonates so strongly: it’s literally the /r/outside MMORPG meme made real. That subreddit (1M+ subscribers) treats real life as an MMO — guilds, quests, XP, loot, character classes. dungeon.club does the same thing but with an actual system behind it.

Reference: https://kotaku.com/if-real-life-was-an-mmo-806521696

Why this matters for virality

  • The vocabulary is already established — people who know /r/outside instantly understand what we’re doing
  • No onboarding needed for the metaphor — “your bookstore is a quest hub” lands immediately
  • The subreddit audience skews toward exactly the kind of people who’d join a gamified bookstore membership
  • It writes its own headline: “This bookstore actually turned itself into an MMORPG guild”

How to use it

  • Lean into /r/outside language in marketing copy and social posts
  • Cross-post or engage with the /r/outside community when we launch
  • Frame the store explicitly as a quest hub in a real-world MMO — not as a loyalty program with game aesthetics

Activity feed with centipoints — shipped

guild PR #12. Redesigned the activity feed to show per-item purchase breakdowns with multiplier math. Switched from fractional floating-point points to integer centipoints (1 cent = 1 point, $15.99 = 1,599 pts). Backfilled purchases flagged explicitly instead of inferring from multiplier. Migration fix: replaced auto-generated migration with targeted IF NOT EXISTS column additions to avoid conflicts with the hand-written multi-tenant migration on staging.

NFC kiosk — shipped

guild PR #17. Full self-serve NFC check-in kiosk: kiosk frontend (dark mode, full-screen), check-in now awards 100 XP, API key auth per device via Kiosks collection. WalletMate II reader types hex UID + Enter as HID keyboard → invisible input captures it. States: idle → loading → welcome (character sheet + XP bar) → auto-reset. Duplicate check-ins show live cooldown timer with draining progress bar. Kiosk API key passed via URL param (?key=) so multiple kiosks can connect to one server.

Also: renamed dedup window to cooldown, configurable via CHECKIN_COOLDOWN_MINUTES env var (default 240). Welcome screen extended to 30s for social media photos. Kiosk e2e tests added.

Emporium — dependency fix

store-rebuild e906ca6: hoisted @medusajs/* packages in .npmrc so Vite can resolve admin dependencies. Added missing direct deps (@medusajs/dashboard, @tanstack/react-query).

Deployment fix

guild 3f1f1d8: switched from payload migrate CLI to prodMigrations at init time — works with standalone Next.js output in Docker (no CLI needed).

What’s next