Skip to content

Game Engine

The engine evaluates challenges and curated stamps against user state when a signal fires, writes progress, and grants reward curios on completion. It runs async via Asynq: producers fire signals, handlers walk rules, resolvers write earns.

Source: backend/internal/services/gameengine/ plus the Asynq handler dispatch in backend/internal/async/handlers/event.go.

Domain wiring

The engine is the seam between the two gamification domains. It reads the content catalog and writes the passport earned ledger, both through interfaces passed to NewEngine (engine.go):

Reads (content) Writes (passport)
LibraryStore (challenge / curio definitions) ChallengeProgressRepository (challenge_progress)
StampGuideStore (guide-entry match) UserStampProgressRepository (user_stamp_progress)
CurioGrantStore (user_curios_earned + curio_earn_events)

It also reads passport.TravelLogStore, FirstDiscoveriesRepository, friend.FriendStore, and CoExperienceRepository for rule leaves. The engine owns no tables of its own.

Architecture

producer service → Signal (TravelLogRecorded | UserActivated)
                      ↓ enqueue Asynq task
                  engine:eval_* | engine:expire_challenge
                      ↓
                  Engine.Evaluate* method
                      ↓
                  challengeResolver.Resolve(ec, actionKinds)
                      │ candidate query: triggers && kinds, opt-in/parent gate, window
                      ↓
                  RuleEvaluator.Evaluate(rule, ec)
                      │ leaf? → leafCatalog (DB-backed)
                      │ composite? → recurse (parallel for non-sequence)
                      ↓
                  challenge_progress upsert + on-complete reward-curio Grant

                  stampResolver.Resolve(userID, row)   (parallel path)
                      │ guide-entry geo match → matched_ids dedup → earn

Failures retry via Asynq exponential backoff. Terminal failures land in the archived queue and are recovered by the admin-triggered replay; the engine entry point stays the same.

Vocabulary

Term What it is
Engine The handler-side interface (gameengine.Engine) called by Asynq tasks. Methods: EvaluateTravelLog(travelLogID), EvaluateUserActivated(userID).
Signal The producer-side interface (gameengine.Signal) services call. Methods: TravelLogRecorded(travelLogID), UserActivated(userID). Production impl enqueues Asynq tasks with Redis throttling for UserActivated.
RuleEvaluator Walks a rule expression tree. Dispatches leaves to the registered evaluator and combines composites per operator.
Leaf One typed measurement node in a rule (visit_count, friend_count, …). Registered in rules/registry.go; implemented as a method on leafCatalog.
Composite Operator node combining children (all_of, any_of, n_of, sequence). Non-sequence operators evaluate children concurrently via errgroup.
Rule The full expression tree on challenges.rule. Leaf form {kind, params} or composite form {op, children[, n]}.
Trigger An ActionKind declaring which signal types can move a leaf. Unioned across a rule at publish time into challenges.triggers.
ActionKind The discrete event type (travel_log, moment, event_attend, …, user_activated). One Signal expands to one or more kinds.
EvalContext Per-evaluation context handed to every leaf (UserContext today). Carries a per-pass EvalCache for single-flight memoization.
challengeResolver Candidate query + per-candidate walk + progress upsert + reward grant.
stampResolver The stamp earn path: guide-entry geographic match + matched_ids dedup + earn. Runs alongside the challenge resolver on every travel-log signal.
ExpiryScheduler / ExpiryHandler Async expiry: scheduler enqueues engine:expire_challenge at the right ProcessAt; handler hard-deletes in_progress rows and archives the challenge.

Signals to ActionKinds

TravelLogRecorded fires whenever a row commits to user_travel_logs. The handler refetches the row, reads source, and emits both the coarse travel_log kind and the source-specific kind (actionKindsForTravelLogSource in actions.go):

Travel-log source ActionKinds emitted
moment [travel_log, moment]
tagged_moment [travel_log, moment, tag_accept]
checkin [travel_log, checkin]
tagged_checkin [travel_log, checkin, tag_accept]
event [travel_log, event_attend]

UserActivated fires from PresenceService on heartbeat / session refresh. The producer throttles on Redis key eng:activated:{user_id} with a 12h TTL (signal_impl.go), roughly one dispatch per session-block, enough to catch day-boundary parent re-aggregation without paying per heartbeat. Emits [user_activated].

Leaf kinds

Every leaf is registered in rules/registry.go and implemented on leafCatalog (leaf_catalog.go). AppliesTo is curio + challenge for all current kinds.

Kind Measures Triggers
visit_count Travel-log entries matching filters (category, tags, geo scope, time window, tagged) travel_log
event_count Event participations (attended / hosted) event_attend, event_host
friend_count Accepted friendships friend
friend_diversity Distinct geographic units among friends friend
first_at_location Locations the user was first to discover travel_log
presence_days Days the user appeared (visit or app open) travel_log, user_activated
geographic_breadth Distinct countries / regions / cities / districts / continents travel_log
category_breadth Distinct location or event categories travel_log, event_host
unique_location_count Distinct locations visited travel_log
stamp_earned_count Curated stamps earned (meta-progression) travel_log
curio_earned_count Curios earned (completionist) travel_log, friend, event_host, user_activated
challenge_completed Other completed challenges (parent aggregation, series, meta) travel_log, friend, event_host, user_activated
co_experience Co-tagged artifacts shared with friends tag_accept, travel_log

Candidate query

ChallengeProgressRepository.ListCandidatesForSignal runs once per signal pass, returning challenges that could move for this user under the supplied action kinds:

SELECT c.* FROM challenges c
LEFT JOIN challenge_progress cp
  ON cp.challenge_id = c.id AND cp.scope_kind = 'user' AND cp.scope_id = $uid
LEFT JOIN challenge_progress pcp
  ON pcp.challenge_id = c.parent_challenge_id AND pcp.scope_kind = 'user' AND pcp.scope_id = $uid
WHERE c.status = 'published'
  AND c.deleted_at IS NULL
  AND c.triggers && $action_kinds                                     -- GIN-indexed
  AND (cp.state IS NULL OR cp.state <> 'completed')
  AND (
    (c.parent_challenge_id IS NULL AND c.optin = false)               -- always-on
    OR (c.parent_challenge_id IS NULL AND cp.scope_id IS NOT NULL)    -- opted-in
    OR (c.parent_challenge_id IS NOT NULL AND pcp.scope_id IS NOT NULL) -- child of opted-in parent
  )
  AND (c.starts_at IS NULL OR c.starts_at <= NOW())
  AND (c.ends_at IS NULL OR c.ends_at >= NOW())

Two PK LEFT JOINs plus the GIN lookup, single round-trip.

Per-pass cache

EvalCache (cache.go) is a mutex-guarded map with single-flight semantics. The challenge resolver builds a fresh cache at the top of each Resolve pass and attaches it to the EvalContext. Leaves opt in via ec.Cache().GetOrCompute(key, fn), so concurrent identical lookups (several friend_* leaves, for example) collapse to one DB round-trip.

Stamp resolver

stampResolver.Resolve (stamp_resolver.go) runs on every TravelLogRecorded signal. Stamps have a fixed earn shape, not a rule tree:

  1. Candidate query (content.StampGuideStore.CandidateStampsForVisit) joins tomoda_stamps to stamp_guide_entries by the visit's geographic match, excluding already-earned stamps. Returns the small published set the visit could move.
  2. Per-stamp match (MatchedEntriesForStamp) returns the entry IDs the row satisfies.
  3. Lazy progress: fetch existing user_stamp_progress.matched_ids, append new entry IDs (deduped), bump current_count, persist. No write when the visit added nothing new.
  4. Earn: when current_count >= minimum_visits, set earned_at. There is no reward-curio path; the stamp is the reward.

matched_ids tracks satisfied guide entries, not raw location IDs. Revisiting a place that already satisfied an entry does not double-count.

Completion and grants

challengeResolver (challenge_resolver.go) upserts challenge_progress after each walk. On the transition to completed, when the challenge has a reward_curio_id, it calls CurioGrantStore.Grant(..., CurioEarnSourceChallenge, ...) in the same transaction. Grants are PK-guarded, so replays and duplicate signals are idempotent.

Retroactive replay

Admin-triggered (no auto-enqueue on publish). Walks historical travel-log rows through the live Engine.EvaluateTravelLog entry point; grants are PK-guarded so replays are idempotent against any prior live or replay run.

POST /api/v1/admin/async/replay/challenge/{id}   → engine:replay_challenge
POST /api/v1/admin/async/replay/stamp/{id}       → engine:replay_stamp

Both enqueue onto the low queue with asynq.Unique(24h) so a second admin click within the window collapses to one in-flight task. The admin routes are in internal/services/admin/routes.go.

Eligibility

The replay service rejects ineligible challenges (ErrChallengeNotReplayEligible). Only published, permanent, always-on, top-level challenges qualify: opt-in challenges, timed cadences, and children of any parent are excluded, because replaying them retroactively would award windows the user never actually participated in. Stamps are always eligible when published (no opt-in or cadence concept).

Walk strategy

ReplayService.ReplayChallenge(id)
  ↓ fetch challenge, check eligibility
  ↓ map challenge.Triggers → travel-log sources
  ↓ DistinctUsersForSources(sources) — candidate user set
  ↓ per user, paged: RowIDsForUser(uid, sources, cursor, 200)
  ↓ per row: engine.EvaluateTravelLog(rowID)

ReplayService.ReplayStamp(id)
  ↓ DistinctUsersForStampGuide(stampID) — candidate user set
  ↓ per user, paged: RowIDsForUserAndStamp(uid, stampID, cursor, 200)
  ↓ per row: engine.EvaluateTravelLog(rowID)

Per-row and per-user errors log and continue; only candidate-query failures propagate as task errors (Asynq retries).

Expiry

When a published challenge has ends_at set, ExpiryScheduler.Schedule(c) enqueues engine:expire_challenge with a ProcessAt:

  • tz_mode = fixed → at ends_at
  • tz_mode = local → at ends_at + 26h (covers the full UTC-12 to UTC+14 span)

The handler hard-deletes challenge_progress rows where state = in_progress for the expired challenge and flips challenges.status = archived. Completed rows persist forever as audit. Idempotent on retry.

Adding a new leaf kind

  1. Add a KindXxx constant in gameengine/rules/registry.go.
  2. Add an xxxSchema() with Triggers declared, register it in the registry map.
  3. Implement the evaluator as a method on leafCatalog (leaf_catalog.go); wire it into leafCatalog.register().
  4. Republish content for any challenges using the new kind; ComputeTriggers recomputes the trigger union.

Admin authoring and bulk import pick up new kinds automatically because validation is registry-driven.

File map

backend/internal/services/gameengine/
  actions.go             — ActionKind taxonomy + travel-log source → kinds
  context.go             — EvalContext interface + UserContext + scope kinds
  engine.go              — Engine interface + production impl + NewEngine wiring
  rule_evaluator.go      — RuleEvaluator + composite operators + sequence semantics
  leaf_catalog.go        — leafCatalog struct + one method per leaf
  challenge_resolver.go  — candidate query + walk + upsert + reward grant
  stamp_resolver.go      — guide-entry geographic match + matched_ids dedup + earn
  cache.go               — per-pass single-flight EvalCache
  expiry.go              — ExpiryScheduler + ExpiryHandler + 26h math
  replay.go              — ReplayService: eligibility, candidate-user query, per-row dispatch
  signal.go              — Signal interface + no-op impl
  signal_impl.go         — production Signal: Asynq enqueue + Redis throttle
  rules/
    expression.go        — Expression type, IsLeaf/IsComposite
    schema.go            — RuleSchema, ParamSpec, ParamType
    registry.go          — kind constants + schemas + trigger strings
    triggers.go          — ComputeTriggers(expr) union helper
    validate.go          — path-precise write-time validation
  • Content - the catalog of challenges / stamps / curios the engine reads
  • Passport - the earned ledger the engine writes; fires TravelLogRecorded
  • Presence - fires UserActivated from heartbeat
  • Async - Asynq queues + scheduler infrastructure