Skip to content

Talent Trees

Try the interactive example ↓

Server-authoritative progression trees: nodes with per-rank costs, AND/OR prerequisites, tier gates, and mutually exclusive branches; XP curves that convert experience events into spendable points; full and partial respecs with refunds; versioned definitions with declarative player migration; and build analytics.

When to reach for it: RPG skill trees with exclusive specializations, learning platforms where exams earn points that unlock electives, SaaS power-user feature ladders driven by usage XP, certification tracks with prerequisites.

When not to: customer points with a redeemable reward catalog and no unlock graph belong to Loyalty; one-time unlock badges belong to Achievements; step-by-step objectives with completion rewards belong to Quest.

Concepts

  • A tree is data - each node has a tier, max_rank, multi-currency cost_per_rank, optional all_of/any_of prerequisites, an optional exclusive_group, and optional per-rank grants.
  • Tier gates hold a tier closed until points spent in the tree and/or required nodes are met; exclusive groups lock out sibling branches until a respec.
  • Points live in one per-user wallet, keyed by free-form currency strings; several trees can spend from the same currency.
  • XP curves turn experience into points. XP accumulates per curve; each level-up credits points_per_level plus any bonus_levels extras. Kinds: linear (level N at per_level * N XP), polynomial (base * N^exponent), table (explicit thresholds).
  • Allocation is server-authoritative - one atomic script checks rank cap, prerequisites, tier gate, exclusive group, and balance, each with its own 409 code, so a modified client cannot cheat.
  • Definitions are versioned - publishing requires a migration plan and players migrate lazily.

Authoring (admin)

snug talent definitions validate --file ./tree.json   # dry-run a draft
snug talent definitions create --file ./tree.json
snug talent curves create --file ./curve.json

Creating, validating, publishing, and deleting definitions and curves needs a platform admin token; any other caller gets 403 insufficient_permissions (verified). Reading does not - any authenticated caller can get and list them. The document shapes, the validator, and how to publish a new version without breaking existing builds are on Authoring and versioning.

Earning points

snug talent xp grant --curve-id docs4-curve --amount 350 --idempotency-key grant-1
snug talent wallet

The examples use a linear curve with per_level: 100, 2 points per level, and a +1 bonus at level 3 (document on the authoring page). 350 XP lands at level 3 and credits 7 points; captured:

{ "idempotent": false, "old_level": 0, "new_level": 3, "granted": { "docs4-points": 7 } }

Replaying the same idempotency_key is a no-op flagged "idempotent": true; the replay does not echo the original outcome (granted empty, levels 0).

Spending points

snug talent state --tree-id docs4-tree     # annotated view of what you can take
snug talent state --tree-id docs4-tree --user-id <user_id>   # another user's view (self or admin)
snug talent allocate --tree-id docs4-tree --node-id node_ignite --ranks 2

Over HTTP, allocation returns the updated state in the standard envelope; captured live after taking Ignite twice, Firewall, then the tier-2 Inferno:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"node_id": "node_inferno"}' \
  http://localhost:4000/api/v1/talent/docs4-tree/allocate
{
  "status": 200,
  "msg": "OK",
  "data": {
    "user_id": "docs4-talent", "tree_id": "docs4-tree", "tree_version": 1,
    "ranks": { "node_ignite": 2, "node_firewall": 1, "node_inferno": 1 },
    "points_spent": { "docs4-points": 5 },
    "exclusive_selections": { "spec": "node_inferno" },
    "highest_tier_unlocked": 1
  }
}

state annotates every node with what a picker UI needs; captured after the allocations above, with 2 points still in the wallet:

{ "node_id": "node_frostfire", "current_rank": 0, "max_rank": 1, "affordable": true,
  "prerequisites_met": true, "tier_locked": false, "excluded_by": "spec" }

The annotations are independent: affordable reports only whether the wallet covers the next rank, so a node can be affordable and still unreachable because tier_locked or excluded_by is set. Ignore highest_tier_unlocked - the server writes a constant 1 and never updates it. Reading another user's state (GET /api/v1/talent/{tree_id}/users/{user_id}/state) is self-or-admin; anyone else gets 403 insufficient_permissions ("cannot act on another user's progression", verified with a second token).

Allocation rules

Every rule violation is a 409 with its own error code; msg spells out the specifics.

error codemsg tail
insufficient_pointsneed 1 docs4-points, have 0
prereq_unmetnode node_firewall requires 2 rank(s) of node_ignite, have 0
tier_lockednode node_inferno is tier 2, which is not yet unlocked
exclusive_group_violatedgroup spec already has a selection
rank_exceedednode node_ignite has max_rank 3, requested rank 4

An unknown node is a 404 node_not_found instead.

allocate and xp grant both take an idempotency_key, and both draw on one key set per user - not one per tree, not one per operation. Reusing a key any earlier call consumed makes the new call a silent no-op: verified by sending an allocation with the key an earlier xp grant had used, which returned 200 with wallet, ranks, and counters unchanged. allocate carries no idempotent flag, so a swallowed call looks like a successful one. Mint a fresh key per operation.

Respec

snug talent respec --tree-id docs4-tree --node-id node_firewall --ranks 1  # partial
snug talent respec --tree-id docs4-tree --full                             # everything

Refunds follow the policy's refund_ratio (at 1.0 a full respec restored the entire spent balance, verified). Two more 409 codes, both reproduced: respec_dependency when removing ranks another allocated node still requires (node node_firewall depends on node node_ignite), and respec_not_allowed when the tree's policy omits or disables that respec kind (full respec is not enabled for this tree). A full-respec policy can also declare a treasury_fee, charged through the Treasury service.

Analytics (admin)

snug talent analytics paths --tree-id docs4-tree       # per-node take rates
snug talent analytics archetypes --tree-id docs4-tree  # build population shares

Two caveats, both verified. paths counts total_ranks_allocated cumulatively and never decrements - a node allocated, respecced away, and allocated again counted twice - so it measures attraction, not currently held ranks. And both reports are stored per tree version, so publishing restarts them from zero: counters standing at 6 under version 1 read 0 the moment version 2 went live. archetypes returns one row per build, as {"signature": "spec=node_inferno", "population": 1, "share": 1.0} plus the selections behind it; players holding no exclusive selection group under no_capstone.

Events

Allocations, respecs, and migrations land on the caller's Timeline stream talent_{user_id}, tagged ["talent", "{tree_id}"], and on the PubSub channel talent.{user_id}.{tree_id} (both captured live). Each carries an event of talent.allocated, talent.respec, or talent.migrated plus a data object of user_id, tree_id, tree_version, and a per-kind detail - for an allocation, node_id, ranks, and ranks_total.

Limits and configuration

Trees are capped at 1000 nodes, 20 tiers, and 8 currencies; a single allocation at 100 ranks and a single XP event at 10,000,000 XP - all tunable via the TALENT_* variables.

Reference

Interactive example

Build progression trees with prerequisites, point costs, and exclusive paths. Allocate points and support respecialization when players change direction.

An illustrative example that runs in your browser. No account or API calls required.