Strategies¶
Who is this page for?
Integrators and strategy authors who decide how many points an event is
worth. This is the conceptual + how-to view. The interpreter internals
(AST, limits, sandbox) are in The DSL Strategy Engine; the per-block editor
reference lives under docs/dsl/.
Which strategy model should I use?
GAME has two ways to author a strategy, aimed at different people:
Built-in classes (Python). Subclasses of
BaseStrategyregistered in the engine. This is the stable, recommended path for engineers who can ship code;defaultis the safe baseline. See Built-in strategies and The Python scoring contract below.The DSL (no-code). Strategies authored visually in the dashboard and stored as
custom:<uuid>, executed in a sandbox. This is the no-code path for designers: production-usable but newer, and its scoring semantics mirror thedefaultbuilt-in. See Custom strategies (the DSL) below.
Rule of thumb: reach for a built-in class when the logic is complex, performance-critical, or ships with the codebase; reach for the DSL when a non-engineer needs to tune scoring without a deploy. Both run through the same engine and are deterministic given identical inputs.
What a strategy is¶
A strategy is the scoring brain bound to a game (and optionally overridden per task). When an event arrives, GAME resolves the effective strategy and asks it for two things:
points- the integer reward, andcaseName- the human-readable label for why that amount was chosen (e.g.BasicEngagement,PeakPerformerBonus). ThecaseNameflows into responses, analytics, and audit so a decision is always explainable.
Strategies come in two families:
Family |
Behavior |
|---|---|
Deterministic |
Fixed, rule-based scoring. Same event → same points, always. |
Adaptive |
Scoring reacts to context: the user’s history, the task’s distribution, comparison to the global average, spatial state, etc. |
Both families are deterministic given identical inputs - adaptivity means the inputs include behavioral/contextual analytics, not that the output is random. That is what makes GAME reproducible (see Overview).
Choosing a strategy: strategyId¶
Every game stores a strategyId; tasks inherit it unless they set their
own. Two id shapes exist:
Shape |
Meaning |
|---|---|
|
A code-defined strategy registered in the engine (table below). |
|
A DSL strategy authored in the dashboard and stored in the database. |
If no strategy is set, the built-in default applies.
Built-in strategies¶
These ship with the engine. Each registers a stable public id (persisted
on games and returned by the API) via @register_strategy - the id is
guaranteed stable across class renames:
|
Version |
Purpose |
|---|---|---|
|
0.0.2 |
The baseline. Awards configurable basic/bonus points; the safe default for any game. |
|
0.0.1 |
Rewards steady, sustained participation rather than bursts. |
|
0.0.2 |
Tuned for the SOCIO-BEE citizen-science scenario. |
|
1.0.0 |
Tuned for the GREENCROWD platform. |
|
0.0.1 |
Tuned for the GREENGAGE scenario. |
|
0.0.1 |
Spatial, experimental. The Getis-Ord \(G_i^*\) hot-spot
computation works standalone, but the strategy is not yet wired
into scoring: |
Discover them at runtime:
GET /api/v1/strategies # list available strategies
GET /api/v1/strategies/{id} # one strategy's metadata
GET /api/v1/strategies/{id}/schema # its configurable variables
GET /api/v1/strategies/{id}/graph # a rendered logic graph
Strategy variables (e.g. variable_basic_points) are the knobs;
game/task params supply their values, so the same strategy behaves
differently per game without code changes.
Extending the engine in code
New built-ins are plain classes that subclass BaseStrategy, implement
the scoring method, and decorate themselves with
@register_strategy(id="..."). Third-party packages can even contribute
strategies through the game.strategies entry-point group - no fork
required. See The DSL Strategy Engine and Codebase Reference.
The Python scoring contract¶
A built-in is a class that subclasses BaseStrategy, registers a stable id,
and implements one coroutine. The signature is the same one BaseStrategy
declares, so subclasses inherit it without surprises:
from app.engine.base_strategy import BaseStrategy
from app.engine.strategy_registry import register_strategy
@register_strategy(id="streak_bonus", version="0.0.1")
class StreakBonusStrategy(BaseStrategy):
async def calculate_points(
self, externalGameId, externalTaskId, externalUserId, data=None
):
# Return (points, caseName): the reward and why it was chosen.
if (data or {}).get("streak", 0) >= 5:
return (10, "StreakBonus")
return (1, "BasicEngagement")
calculate_points returns a (points, caseName) tuple - points is the
integer reward and caseName is the label explaining why that amount was
chosen (it flows into responses, analytics, and audit). A strategy may return
an optional third element, callbackData, to pass structured data back to
the caller. The bundled default strategy
(app/engine/default.py) is the reference implementation of this contract.
Custom strategies (the DSL)¶
The dashboard’s Strategy Editor lets game designers build strategies
visually (Blockly) with no Python. A strategy is a tree of blocks; when
a scoring event arrives the engine walks the tree top-to-bottom and emits the
first assign_points it reaches inside a matching rule.
The custom-strategy lifecycle¶
Custom strategies are first-class, versioned resources:
Operation |
Endpoint |
|---|---|
Create a draft |
|
List / get |
|
Update (creates a new draft of a published strategy) |
|
Publish a version |
|
Archive |
|
List versions |
|
Roll back to a version |
|
Where is it used? |
|
Import / export |
|
Simulate (no persistence) |
|
Versioning guarantees¶
Saving a published strategy creates a
v+1draft rather than overwriting. Production keeps running the published version until you explicitly publish the new one.If a published version misbehaves, rollback restores a prior version. Rollback also rewrites the
strategyIdon every game/task that pointed at the archived version, so the cascade reaches all consumers.
This means you can experiment freely - nothing in production changes until you press Publish.
Simulate before you ship¶
Every strategy can be simulated: scoring runs and you get the full
node-by-node trace, but no production data is touched - no UserPoints,
no wallet movement.
Two ways to simulate:
Per-event - send
"isSimulated": trueto the points endpoint (Integrating with GAME).Per-strategy - call
POST /strategies/custom/{id}/simulate(the editor’s Test button) to dry-run a candidate strategy against sample input and inspect exactly which rule would fire and why.
There is also a per-user simulated view,
GET /games/{gameId}/users/{externalUserId}/points/simulated (OAuth2-only,
bound to the caller’s own subject), returning a simulationHash for
integrity plus per-task projected points.
Use simulation liberally - it is the safe way to validate a scoring change.
Safety limits¶
Published DSL strategies run inside a sandbox with hard ceilings so a runaway rule cannot impact your tenant or others:
Limit |
Default |
Env var |
|---|---|---|
Wall-clock per call |
500 ms |
|
AST nodes visited |
1000 |
|
Recursion depth |
32 |
|
Hitting a limit rejects the event with a clear error code rather than degrading the service. The full execution model - how the interpreter yields cooperatively so the timeout can actually cancel it - is in The DSL Strategy Engine.
Worked example: adaptive engagement¶
A common pattern (from the citizen-science deployments) layers cases by the
user’s measurement count and performance. The caseName values below are
the labels you assign when authoring the strategy in the DSL - illustrative,
not a literal dump of the bundled default built-in’s outputs:
Case |
Condition |
|
|---|---|---|
First/second measurement |
No prior history |
|
Slower than global avg |
|
|
Faster than global avg |
|
|
Beats own history and global |
|
|
The full decision tree and points table is in the repository’s
strategies.md; expressing it in the editor is a matter of nesting
gd_rule blocks with gd_compare conditions and gd_assign_points
leaves.
Recipe: scoring a citizen-science field campaign¶
GAME’s home turf is European citizen-science (the socio_bee,
greencrowd and greengage deployments), not e-commerce. This recipe
scores an air-quality measurement campaign end to end with a real,
non-default strategy and shows the exact output each step produces.
The vehicle is a custom DSL strategy (the no-code path) - the same kind a campaign designer authors in the Strategy Editor. It rewards three moments a field campaign actually cares about:
a completed task - an onboarding award while the task is still new,
a priority-zone bonus - a measurement taken where coverage is thin, and
a streak - consecutive days of participation, capped so it cannot run away.
The program¶
A DSL_FULL strategy is a top-down list of rules (first match wins) plus a
default fallback. Every field it reads is on the engine whitelist:
task.measurements_count is an analytics value counted from the database,
while data.priority_zone and data.streak_days are scalars the client
sends with each measurement event.
{
"type": "program",
"rules": [
{ "type": "rule",
"when": {"type": "compare", "op": "<",
"left": {"type": "field", "path": "task.measurements_count"},
"right": {"type": "literal", "value": 2}},
"then": [{"type": "assign_points",
"value": {"type": "literal", "value": 5},
"case_name": "TaskCompleted-Onboarding"}] },
{ "type": "rule",
"when": {"type": "compare", "op": "==",
"left": {"type": "field", "path": "data.priority_zone"},
"right": {"type": "literal", "value": 1}},
"then": [{"type": "assign_points",
"value": {"type": "arith", "op": "+",
"left": {"type": "literal", "value": 10},
"right": {"type": "literal", "value": 15}},
"case_name": "PriorityZoneBonus"}] },
{ "type": "rule",
"when": {"type": "compare", "op": ">=",
"left": {"type": "field", "path": "data.streak_days"},
"right": {"type": "literal", "value": 3}},
"then": [{"type": "assign_points",
"value": {"type": "func_call", "name": "clamp", "args": [
{"type": "arith", "op": "*",
"left": {"type": "field", "path": "data.streak_days"},
"right": {"type": "literal", "value": 5}},
{"type": "literal", "value": 0},
{"type": "literal", "value": 50}]},
"case_name": "StreakBonus"}] }
],
"default": {"type": "assign_points",
"value": {"type": "literal", "value": 2},
"case_name": "TaskCompleted"}
}
Author it with POST /strategies/custom, publish it, and point a game at the
resulting custom:<uuid> id (see The custom-strategy lifecycle and
Integrating with GAME). Then every measurement is a points call carrying the
campaign’s discriminators:
POST /api/v1/games/{gameId}/tasks/air_quality_measurement/points
{ "externalUserId": "citizen_ada",
"data": { "priority_zone": 1, "streak_days": 0 } }
Important
The DSL has no “missing key defaults to zero” operator: comparing an absent
data.* key against a number raises DSL_COMPARE_TYPE_MISMATCH. A real
integration therefore sends every discriminator on every event (here
priority_zone and streak_days, defaulting to 0), exactly as a
structured measurement payload would.
What it scores¶
Walking the campaign produces these results, captured from a real run (not hand-written):
Moment |
Event |
|
|
|---|---|---|---|
Completed task, task still new |
|
5 |
|
Priority-zone measurement |
|
25 |
|
Streak, day 4 |
|
20 |
|
Streak, day 20 (capped) |
|
50 |
|
Completed task, has history |
|
2 |
|
The first and last rows send the same event yet score differently: the
onboarding rule only fires while task.measurements_count is below two, so
once the task has history the program falls through to its default. That is
the analytics field doing its job - the same input scores differently as the
campaign matures.
Run it yourself¶
The recipe is backed by an executable test that wires the real engine
(validate_ast -> ExecutionContext -> DslInterpreter ->
DslStrategy) against an isolated SQLite database and asserts every row of
the table above:
poetry run pytest tests/recipes/test_recipe_citizen_science_s31.py -q
Run the same file directly to print the captured output step by step:
PYTHONPATH=. poetry run python tests/recipes/test_recipe_citizen_science_s31.py
Note
This recipe uses a DSL strategy so it runs deterministically with no
external infrastructure. The bundled socio_bee / greengage built-ins
target the same domain but reach their bonus branches only after enough
timing history has accumulated, which makes them harder to reproduce in a
short walkthrough.
Tests as documentation¶
The promise that a custom:<uuid> DSL strategy scores identically to its
built-in twin is not just prose - it is pinned by an executable parity suite.
These tests are the proof; a change that broke the equivalence would fail
them:
Test |
What it pins |
|---|---|
|
The |
|
The same equivalence for |
|
A |
|
End-to-end wiring: a task bound to a |
Run them with the unit suite (see Contributing):
poetry run pytest \
tests/unit_tests/engine/test_default_dsl_parity.py \
tests/unit_tests/engine/test_constant_effort_dsl_parity.py \
tests/unit_tests/engine/test_default_extend_parity.py \
tests/unit_tests/services/test_user_points_service_dsl.py
The interpreter-level view of the same guarantee - the DSL_EXTEND pipeline
and the property-style invariants - is in The DSL Strategy Engine.
Observability¶
Every production run of a custom strategy emits Prometheus metrics
(dsl_execution_duration_seconds, dsl_execution_nodes_total,
dsl_execution_errors_total) and, on failure or by sampling, persists a
StrategyExecutionLog row you can inspect later. Aggregations are available
at GET /strategies/custom/{id}/metrics and an A/B view at
GET /strategies/custom/compare. See Observability.