ServicesAchievement

Rule Types

Every achievement definition carries one rule under --rules, tagged by type. The rule decides which events advance the achievement and what its progress payload looks like in event responses, in-progress queries, and live WebSocket frames. All time fields are in seconds.

counter

Count occurrences of one event type.

{"type": "counter", "event": "enemy.defeated", "target": 3}
{"type": "counter", "current": 2, "target": 3, "percentage": 66.66667}

Optional filters is a map of event_data fields that must match for the event to count (a JSON-array filter value means "must be one of"). Optional window makes the counter rolling: only events within window seconds of the first counted event accrue, and once the window lapses the count restarts at 1 from the current event - verified with a 3-second window, where a third event after a 4-second pause reset current to 1 and restarted window_started_at.

threshold

Reach a metric value in a single event, compared with gte, gt, lte, lt, or eq.

{"type": "threshold", "metric": "score", "value": 1000, "comparison": "gte"}

A threshold rule has no event field, and that is the gotcha: it evaluates on every event the user submits, reading event_data[metric] from whichever event arrives. An unrelated event type moved the achievement to in_progress (with no progress, since the field was absent), and a later event of a different type carrying "score": 1450 completed it. Its progress payload reports as a counter:

{"type": "counter", "current": 1450, "target": 1000, "percentage": 100.0}

streak

Consecutive events with at most interval seconds between them.

{"type": "streak", "event": "workout.done", "count": 2, "interval": 3600}
{"type": "streak", "current_streak": 2, "target": 2,
 "last_event": "2026-08-28T13:32:08Z", "expires_at": "2026-08-28T14:32:08Z"}

A gap longer than interval plus the optional grace_period resets the streak to 1. expires_at is the deadline for the next qualifying event.

accumulator

Sum (or track) a numeric event_data field across events until target.

{"type": "accumulator", "event": "run.logged", "field": "distance_km",
 "target": 42, "operation": "sum"}
{"type": "accumulator", "current": 30.0, "target": 42.0, "percentage": 71.42857}

operation is sum (default), max, or min, tracking the running total, largest, or smallest single-event value; completion always fires when the tracked value reaches target (avg is accepted but currently behaves as sum). Verified: 30 km + 12 km completed a 42 km sum. An event missing the field is skipped for this rule - the response simply carries no progress entry for it.

collection

Collect distinct values of an event_data field.

{"type": "collection", "event": "town.visited", "field": "town",
 "required": ["rivertown", "hilltop"], "unique": true}
{"type": "collection", "collected": ["rivertown"],
 "required": ["rivertown", "hilltop"], "percentage": 50.0}

With required, every listed value must be collected (repeats do not advance progress - verified: visiting rivertown twice stayed at 50%). Alternatively required_count completes after N distinct values of the field, whatever they are. ordered requires the required values to arrive in order.

composite

Combine nested rules with and / or. Nesting recurses - a composite can contain composites.

{"type": "composite", "operator": "and", "rules": [
  {"type": "counter", "event": "run.logged", "target": 1},
  {"type": "counter", "event": "workout.done", "target": 1}
]}
{"type": "composite",
 "sub_progress": [
   {"type": "counter", "current": 1, "target": 1, "percentage": 100.0},
   {"type": "counter", "current": 0, "target": 1, "percentage": 0.0}
 ],
 "completed": [true, false], "completed_rules": 1, "total_rules": 2,
 "percentage": 50.0}

An event advances every sub-rule that matches its type. The definition-wide rule cap (10 by default) counts nested rules.

time_limited

Complete an inner rule within time_limit seconds.

{"type": "time_limited", "time_limit": 3600,
 "rule": {"type": "counter", "event": "run.logged", "target": 2}}
{"type": "time_limited",
 "inner_progress": {"type": "counter", "current": 1, "target": 2, "percentage": 50.0},
 "time_remaining": 3599, "expires_at": "2026-08-28T14:32:08Z"}

The deadline is anchored when the user's progress row is first created - their first event or profile read after the achievement exists - and does not slide on later events. Naming a start_event changes that: each time it fires, the window restarts from that moment. A background sweep (60-second interval by default) marks overdue in-progress achievements expired; time_remaining also surfaces in in-progress queries and profile entries.

Choosing between them

Counter vs accumulator: count events vs sum a field. Threshold vs accumulator: one event reaching a value vs an accumulated total. Streak vs windowed counter: maximum gap between consecutive events vs total time from the first event. For milestone nudges, in-progress queries derive next_milestone (25/50/75/100%) from any rule's progress.

On this page