ServicesDirector

Director

A tension-curve pacing engine in the mold of Left 4 Dead's AI Director. An admin authors a pacing policy - a target intensity curve plus an estimator, per-signal weights, and budgets - and each session runs against it: it ingests weighted signals ("damage taken", "death"), keeps a decaying intensity estimate, and hands back budgeted pacing decisions - spawn a wave, trigger the climax, ease off, hold - by poll or push.

When to reach for it: horror/co-op encounter pacing, adaptive difficulty from a struggle index, notification drip control, supply drops timed to sustain tension, onboarding that slows when friction rises.

When not to: plain scheduling is the Job Queue; feature gating and rollouts belong to Remote Config.

Concepts

  • Policies are versioned - creating a policy with an existing id appends a new immutable version; reads return the newest. A session pins the version that was current when it started.
  • The target curve describes one build-peak-relax cycle: a shape (sawtooth_relax, sine, square, triangle, constant), cycle_seconds, floor/peak intensity (0.0-1.0), and a tolerance_band considered "on target". The position in the cycle determines the phase: build_up, peak, or relax.
  • Signals are weighted - a signal's type must appear in the policy's signal_weights; each one adds weight x magnitude to the estimate (negative weights cool things down). Unknown types are rejected.
  • The estimate decays lazily - between reads it decays exponentially toward the curve floor with decay_half_life_seconds; there is no background tick.
  • Decisions spend budget - every decision read consumes from per-cycle spawn/event budgets and respects peak cooldowns; budgets replenish each new cycle. See Decisions and delivery.
  • Deterministic seeds - each session carries a seed (explicit or derived) and every decision draw comes from (seed, decision_index), so decision sequences replay exactly.

Author a policy

Policy authoring requires a platform admin token or the director_designer capability - without one, create and analytics fail with 403 insufficient_permissions (verified). Policies are written as a JSON file:

{
  "id": "docs4-policy-survival",
  "target_curve": {
    "shape": "sawtooth_relax", "cycle_seconds": 180,
    "peak_intensity": 0.9, "floor_intensity": 0.2, "tolerance_band": 0.1
  },
  "estimator": { "decay_half_life_seconds": 30, "clamp": [0.0, 1.0] },
  "budgets": {
    "spawn_budget_per_cycle": 12, "event_budget_per_cycle": 1,
    "min_seconds_between_peaks": 45, "ease_off_min_seconds": 20
  },
  "signal_weights": { "damage_taken": 0.08, "death": 0.4, "objective_complete": -0.15 }
}
snug director policy create --file ./survival.json   # -> version 1
snug director policy create --file ./survival.json   # same id -> version 2
snug director policy versions --policy-id docs4-policy-survival
snug director policy get --policy-id docs4-policy-survival   # newest version

Optional: deterministic.per_session_seed (default true) and a variant_split for A/B tests (see below). Reading a policy needs no role.

Run a session

snug director session start --policy-id docs4-policy-survival \
  --session-id docs4-sess-a --seed 42
snug director session get --session-id docs4-sess-a

Session ids are auto-generated (sess_<uuid>) when omitted; supplying one that already exists fails with 409 session_already_exists (verified). --scope labels the context (per_session, per_cohort, per_user) and --variant pins an A/B label. The estimate starts at the curve floor.

Over HTTP (not exposed by the CLI), session creation also accepts a world_context ({"use_virtual_time": true, "world_id": "..."}) advancing cycle time on an Almanac world clock instead of wall time; an unresolvable world fails with 424 world_clock_unavailable (verified).

Report signals

snug director signal --session-id docs4-sess-a --type damage_taken --magnitude 2.0

On a fresh session this returned an estimate of 0.36: floor 0.2 plus 0.08 x 2.0. A type missing from signal_weights is 400 unknown_signal_type (verified). Over HTTP, signals batch (up to 100), and respond: true returns a decision in the same call:

curl -s -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"signals":[{"type":"damage_taken","magnitude":1.5},{"type":"death","magnitude":1.0}],"respond":true}' \
  http://localhost:4000/api/v1/director/sessions/docs4-sess-a/signal
{
  "status": 200,
  "msg": "OK",
  "data": {
    "session_id": "docs4-sess-a",
    "accepted": 2,
    "estimate": { "intensity": 0.8667528764184694, "as_of": "2026-08-28T06:31:21.771104Z" },
    "budgets_remaining": { "spawn": 12, "event": 1 },
    "decision": {
      "phase": "build_up",
      "current_intensity": 0.8667528764184694,
      "target_intensity": 0.45334944444444447,
      "action": { "type": "hold", "size": 0, "cost": 0, "cooldown_seconds": 0.0 },
      ...
    }
  }
}

Ask for a decision

snug director decision --session-id docs4-sess-a

Polling compares the decayed estimate to the curve and emits spawn_wave, trigger_climax, ease_off, or hold with a rationale - and it is not a pure read: it advances the decision index and can spend budget. The rules, budgets, determinism, and push delivery over PubSub, webhooks, or WebSocket: Decisions and delivery.

Replay the arc

snug --output json director curve --session-id docs4-sess-a

Returns the target_curve, every trajectory point (realized_intensity vs target_intensity plus phase, one per signal report or decision read), and the decision timeline - enough to plot or replay the session.

Outcomes and analytics

Record how sessions ended, then read curve-adherence analytics per policy:

snug director outcome --session-id docs4-sess-a --kind completion
snug director policy analytics --policy-id docs4-policy-survival --variant-breakdown

Analytics (admin/designer only) aggregates time_in_band_ratio, peak_count, mean realized vs target intensity, and outcome tallies (completion / rage_quit / abandon), overall and per variant. For A/B tests, give the policy a variant_split with weighted arms and an optional Remote Config gate_flag_key: sessions started without an explicit variant get a sticky assignment - verified: three sessions from one user all landed in the same arm. Variant-less sessions tally under default.

Limits and configuration

Policy and session ids are capped at 128 characters, signal batches at 100 per request, and the retained trajectory/decision/outcome series at 10,000 entries each - all tunable via the DIRECTOR_* variables in the Director CONFIG reference.

Reference

On this page