Skip to content

Challenges — Domain Spec

A challenge is the engagement unit — a rule, a window, an opt-in mode, and optionally a reward curio. Completing it writes to your challenge_progress audit and grants the reward (if any) into your Tomoda Collection.

Mental model

Five axes describe any challenge:

Axis Values Meaning
Cadence (duration) permanent, day, week, month, special Window shape. Permanent = no expiry. Day/week/month = recurring. Special = custom start/end.
Tz mode local, fixed Whether the window aligns to the user's local timezone (local) or to UTC (fixed).
Scope user, circle What entity is evaluated. Circle scope lands when circles ship.
Opt-in mode true / false Always-on (passive) vs opt-in (user must start).
Parent nullable challenge FK Children inherit the parent's opt-in. Engine only evaluates a child when the parent has a progress row.

The rule itself is described in Game Rules.

Always-on vs opt-in

Mode Use for Where it surfaces
Always-on (optin=false, no parent) Permanent slow-burn achievements, global event challenges Tomoda Collection's "In progress" once the first qualifying action moves it
Opt-in (optin=true, no parent) Day / week / month quests; user-pulled engagement loops Quests panel — pickable card; once started, moves to "In progress"
Child (parent_challenge_id IS NOT NULL) Composition under a parent (e.g. four weeklies in a monthly arc) Surfaces under the parent in both quests panel and progress views

Children's own optin value is ignored — parent controls.

Lifecycle

Phase Entered when Exit
draft Admin saves Admin publishes
published Admin publishes (triggers triggers recompute + expiry scheduling) Window closes OR admin archives
archived Expiry handler fires for time-bounded; admin archives manually for permanent (Terminal)

Time-bounded published challenges get an engine:expire_challenge Asynq task enqueued at publish time, scheduled at ends_at for tz_mode='fixed' or ends_at + 26h for tz_mode='local'. The handler hard-deletes in-progress rows and flips status to archived. Completed rows persist forever as audit.

Opt-in API

Endpoint Method Action
/api/v1/challenges/:id/start POST Create challenge_progress(scope=user, scope_id=$uid, challenge_id, state='in_progress', leaf_state={}). Idempotent. 200 ok or already present; 409 if completed; 404 if unpublished/archived.

No user-scope leave endpoint. Started rows persist until completion or scheduled expiry. The "I no longer want this" path is "don't engage", and the row gets cleaned up when the window closes. Circle leave + reset lands when circles ship.

Progress

challenge_progress (scope_kind, scope_id, challenge_id) PK row, plus state ∈ {in_progress, completed}, completed_at, leaf_state JSONB.

  • Lazy creation: always-on challenges only get a row when something actually moves (or when the user opts in for opt-in challenges).
  • Single state transition: in_progress → completed. No "abandoned" state (cleanup at expiry).
  • leaf_state: per-leaf persistent state — sequence anchors, matched_id lists for stateful leaves. Format mirrors the rule tree.

Retroactive eligibility

Admin can trigger a retroactive replay on a published challenge — every existing user's historical travel log is walked through the engine, with grants applied where they qualify. Only a narrow subset of challenges are eligible:

Challenge shape Replay-eligible
Permanent + always-on (optin = false) + top-level (no parent)
Opt-in (optin = true) ✗ — user must explicitly start
Day / week / month / special cadence ✗ — time-bounded windows don't apply to history
Child of any parent ✗ — inherits parent's opt-in state

Auto-enqueue on publish or republish is off; admin must click Replay explicitly. Idempotent: grants are PK-guarded so re-running is safe.

Reward grant

On completion: if reward_curio_id IS NOT NULL, the engine grants the curio via user_curios_earned PK + INSERT … ON CONFLICT DO NOTHING. Audit row always written to curio_earn_events with source = 'challenge', source_id = challenge_id.

See also