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.