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:
- Candidate query (
content.StampGuideStore.CandidateStampsForVisit) joinstomoda_stampstostamp_guide_entriesby the visit's geographic match, excluding already-earned stamps. Returns the small published set the visit could move. - Per-stamp match (
MatchedEntriesForStamp) returns the entry IDs the row satisfies. - Lazy progress: fetch existing
user_stamp_progress.matched_ids, append new entry IDs (deduped), bumpcurrent_count, persist. No write when the visit added nothing new. - Earn: when
current_count >= minimum_visits, setearned_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→ atends_attz_mode = local→ atends_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¶
- Add a
KindXxxconstant ingameengine/rules/registry.go. - Add an
xxxSchema()withTriggersdeclared, register it in theregistrymap. - Implement the evaluator as a method on
leafCatalog(leaf_catalog.go); wire it intoleafCatalog.register(). - Republish content for any challenges using the new kind;
ComputeTriggersrecomputes 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