| name | Path & Gamification Architecture |
|---|---|
| description | The Path progression system, critter gacha rewards, koi/gold economy, clearing gallery, and admin review workflow |
| type | project |
| originSessionId | bb8ce051-7e1a-4ccd-bd96-7b3a575d339a |
The Path is the primary user-facing experience — a 3D perspective ground plane where students progress by creating journal entries. Each entry earns a critter (gacha reward) and advances the user along the path.
Single index action at /path. Trial users can access (allow_trial_access).
Props passed to frontend:
user— nested object:id,display_name,koi,gold,avatar(no email/PII)has_projects— whether user owns or collaborates on any projectjournal_entry_count— number of kept journal entries (owned + collaborated)critter_variants— array of variant strings per journal entry (creation order), nil where no critter was awardedpending_dialog— key of the next campaign dialog overlay to show (sixty_hours,streak_goal_completed,first_journal,streak_goal_nudge), or nilmail_intro_id— id of a mail to auto-open on load, or nil
Also exposes has_unread_mail, current_streak, and unsubmitted_hours via path-scoped inertia_share.
activePathNodeIndex = has_projects ? Math.min(journal_entry_count + 1, pathNodeCount - 1) : 0
pathNodeCount = Math.max(50, 50 + journal_entry_count) — minimum 50 nodes, grows so there's always at least one locked node ahead. (PathNode itself derives its own activeIndex = has_projects ? journal_entry_count + 1 : 0 for state.)
Nodes on the path:
- Index 0 (star node):
active→<Link>to/projects/onboarding;completed→ notification - Index 1+ (journal nodes):
active→ModalLinkto/journal_entries/new(trial users / docs-nudge get a notification instead);completed→ shows critter image overlay - States:
locked(future),active(next to complete),completed(past)
Pure CSS 3D transforms + canvas — not Three.js. See test-page-architecture.md for full implementation details.
Key architecture:
rotateX(60deg)ground plane withperspective: 800px- Two-layer billboard system (front/back) for depth sorting with hill cover div between them
- Canvas-based grass sprites with manual 2D perspective projection matching CSS math
- Native scroll drives position — spacer div creates scroll height, fixed viewport floats over it
- All updates bypass React via refs +
requestAnimationFrame— React renders once on mount - Planet curvature via
translateZ(-d²/2R)— billboards sink below horizon at distance - O(1) culling with
display: nonefor off-screen billboards
| Component | Purpose |
|---|---|
Path.tsx |
3D scene — billboards, grass, scroll, curvature math (~1030 lines); takes nodes + introTransition props |
PathNode.tsx |
Individual node — state logic, tooltip, Link/ModalLink for active node |
Header.tsx |
Top HUD — avatar, display name, koi + gold balance, sign-out dropdown |
Leaderboard.tsx |
Placeholder — "Coming Soon" |
BgmPlayer.tsx |
Background music toggle |
SignUpCta.tsx |
Trial user upgrade prompt |
PathDialogOverlay.tsx |
Campaign dialog/cutscene overlay (mascot, stepped text, choices) driven by pending_dialog |
SubmissionCountdown.tsx |
Countdown HUD to submission deadline |
Gacha rewards earned from creating journal entries.
Variants are derived at boot from the image files in public/critters/*.webp — not a hardcoded list. Critter::ALL_VARIANTS is the sorted list of basenames; SHINY_VARIANTS are those prefixed shiny-; VARIANTS is the non-shiny remainder. Adding/removing a .webp changes the pool. validates :variant, inclusion: { in: ALL_VARIANTS }.
Shiny rolls: Critter.roll_variant picks a SHINY_VARIANTS.sample with probability SHINY_CHANCE (0.05), otherwise VARIANTS.sample. critter.shiny? checks the prefix.
Fields:
variant— set viaCritter.roll_variantspun— boolean, default false (whether reveal animation played)user_id,journal_entry_id
Helpers: image_path → /critters/<variant>.webp; audio_path → per-variant /sfx/spin/<variant>.mp3 falling back to default.mp3; mark_spun!.
Live updates: includes Broadcastable; broadcasts_updates_to { "path_user_#{user_id}" } so the owner's path page re-hydrates critter_variants on change.
Award flow (JournalEntriesController#maybe_award_critter):
- Check
user.can_earn_critter?(returns!trial?) user.critters.create!(variant: Critter.roll_variant, journal_entry:)- Also awards critters to journal-entry collaborators (
award_critters_to_collaborators) - On create, the controller redirects to
critter_path(critter)(/spin/:id) for reveal; otherwise back to the path or project
Policy (CritterPolicy): show/update restricted to owner only; scope resolves to the user's own critters.
GET /spin/:id→ plays/spin_animation.mp4, reveals critter image after 1.6sPATCH /spin/:id→ marks critter asspun: true- Options: replay animation or visit the Clearing
Gallery view at /clearing showing all the user's critters (policy_scope(Critter), newest first) in a scenic environment.
- Blue-noise distributed placement algorithm
- Links to individual
/spin/:idfor each critter - "Back" link returns to
/path
Implemented as a ledger. See arch-ship-and-koi.md for the full model. Quick reference:
KoiTransaction(readonly,REASONS = ship_review | built_irl_conversion | admin_adjustment | streak_goal;SHIP_REASONS = ship_review | built_irl_conversionrequire aship_id) and parallelGoldTransaction(REASONS = ship_review | built_irl_conversion | admin_adjustment).User#koi=koi_transactions.sum(:amount)MINUSfrozen_koi_amountof non-rejected shop_orders MINUSfrozen_koi_amountof kept, non-rejected project_grant_orders. Trials hardcoded to 0.User#goldis computed identically againstGoldTransactionandfrozen_gold_amount— both balances are recomputed live from their ledgers; there is no denormalized counter. Both shop orders and grants store a koi-first cost split (koi spent before gold; gold can pay for koi-currency items but not vice-versa).- Reasons are wired:
streak_goal(StreakService),admin_adjustment(Admin::KoiTransactionsController),ship_review/built_irl_conversion(Ship's after_update_commit → awarder service). See arch-ship-and-koi.md §10 for the awarding formula and the layered safeguards (DB partial unique index, model invariant, reconciliation rake task). - Spending paths:
ShopOrder(frozen_price + koi-firstfrozen_koi_amount/frozen_gold_amountsplit, item currency koi/gold/hours),ProjectGrantOrder(koi → USD via HcbGrantSetting for HCB project funding cards). Both use frozen amounts and excluderejectedfrom the deduction (sofulfilled→rejectedrefunds).
See arch-ship-and-koi.md for the full pipeline. Quick reference:
- 4-stage pipeline: Phase 1 = TimeAuditReview + RequirementsCheckReview (parallel, both required). Phase 2 = DesignReview OR BuildReview depending on
ship.ship_type, created only after Phase 1 approves. - All reviews share the
Reviewableconcern (claim system with 5min TTL, atomic claim, heartbeat, lock_version, terminal-status protection). - Reviewer roles:
time_auditor,requirements_checker,pass2_reviewer(gates Phase 2). Plusadmin(everything) andhcb(real-money gate, separate). - Per-type queues:
/admin/reviews/{time_audits,requirements_checks,design_reviews,build_reviews}. Catch-all summary at/admin/reviews(Admin::ShipsController). - Status transitions on Ships and Reviewables are model-validated against terminal states — admins can't bypass via direct edit; they must go through the pipeline.
MailDeliveryService.ship_status_changed(ship)fires after_update_commit on status change.
In-app notification system (not email — confusingly named "mail").
Features:
- Broadcast or targeted (single user, role-based, activity-filtered)
- Optional expiration (
expires_at) - Dismissable and pinnable
MailInteractiontracks read/dismissed per user
Filters (JSON column): roles, joined_before/after, has_projects, has_ships_with_status, user_ids
Delivery triggers:
MailDeliveryService.ship_status_changed(ship)— on review status changeMailDeliveryService.collaboration_invite_sent(invite)— on invite creation (non-dismissable)
Controller (app/controllers/mails_controller.rb):
- Uses default auth chain (no
allow_trial_access, noallow_unauthenticated_access) — full HCA authentication required index—policy_scope(MailMessage), ordered by pinned then created_at. Tracks read/dismissed state per user.show— finds message, authorizes, auto-marks as read viaMailInteractiondismiss— marks message dismissed viaMailInteractionread_all— bulk mark-all-read. Usesskip_authorization(operates on user's own interactions only, not a model-level action)
Frontend: /mails inbox with mark-all-read and individual dismiss.
ClearingController supports a ?simulate param in development that renders all Critter::ALL_VARIANTS as mock data — useful for testing the gallery layout without creating real journal entries.
Tracks per-user state for each MailMessage:
read_at— when user opened the messagedismissed_at— when user dismissed it- Unique constraint:
(mail_message_id, user_id) - Scopes:
.read(read_at present),.dismissed(dismissed_at present)
Public form at POST /rsvp (RsvpsController):
allow_unauthenticated_access+ no Pundit (public endpoint)- Validates email format
- Posts to Airtable table via API
- Stores IP address + email
- Rate-limited: 5/min per IP
- Dismissable banner on landing page (localStorage)