Skip to content

Research & Tech Tree

Purpose

Research is the empire's progression currency. A player accrues research points (RP) passively from the research labs on their colonized planets, banks them on a per-player ledger, and spends them to unlock nodes in a static tech tree. Each node, once unlocked, switches on a capability that the rest of the game reads at the moment it needs it: a new building becomes placeable, a tool becomes available, a ceiling rises, or a curve bends. The tree is the single gate currency that ties colony output (labs make RP) back into colony capability (RP unlocks content).

The defining discipline is that research is a leaf in the call graph. Effects are read at point-of-use — production, combat, terraform, and citadel code query the tree and the player's ledger when they compute a value. A research effect is never written onto the entity it buffs, so unlocking a node mutates no planet, ship, or combat column. The tree grows by appending catalog rows, not by migrating buffed systems.

The research-point currency

RP is a flat, integer currency banked on the per-player research ledger (Player.research_ledger, a nullable JSONB column). The ledger shape:

{
  "rp": 0,                          // banked, spendable research points
  "insight": 0,                     // reserved second/third currencies; read 0 today
  "doctrine": 0,
  "unlocked": ["t.root.0"],         // node ids the player has unlocked — the durable spend record
  "swept_at": "2026-..."            // first-sweep stamp; gates the one-time wipe+refund (below)
}

A null column means a never-researched player and reads as the cold-start default {rp:0, insight:0, doctrine:0, unlocked:["t.root.0"]} — the free root is always present. The ledger is read lazily: a pure read of a null column returns a fresh default without persisting it; only the mutating paths (unlock_node, the faucet sweep) assign the seeded dict back to the column and flag it modified. unlocked is the only durable record of what RP bought — it stores node ids, so appending new catalog rows never invalidates an existing ledger.

The faucet that fills the ledger is the shipped per-planet active_events['research_points'] balance, accrued at 25 RP per lab level per day (RESEARCH_POINTS_PER_LAB_LEVEL_PER_DAY). The planetary tick drains that faucet into the owner's ledger (see the faucet sweep below).

The tech-tree node catalog

The catalog is a module-level static dict (tech_tree.TECH_NODES), mirroring the shape of CITADEL_LEVELS / DEFENSE_BUILDINGS: the game queries it and never mutates it. A CATALOG_VERSION constant is bumped when the catalog's shape or contents change; because the ledger stores unlocked node ids, appending rows is forward-compatible.

Each node:

{
  "id":      "t.defense.railgun.1",   // stable dotted id, unique
  "branch":  "defense",               // one of BRANCHES
  "tier":    1,                       // 0 = root
  "name":    "Rail Gun Emplacements", // display
  "cost":    { "rp": 50 },            // flat RP only
  "prereqs": ["t.root.0"],            // node ids that must be unlocked first (DAG edges)
  "effect":  { "kind": "content_unlock", "key": "rail_gun" }
}

Branches and the free root

Five branches share a single Tier-0 root: production, defense, ships, exploration, terraforming. The branch vocabulary is pinned by the BRANCHES constant so later rows validate against it.

The free root is t.root.0 ("Applied Science"), tier 0, cost 0 RP, with no prerequisites. It is the only prereq-less node, and every other node is reachable from it by following prereq edges. A cold-start ledger lazy-seeds unlocked: ["t.root.0"], so every player starts already holding the root.

Effect kinds

Each node carries an effect.kind from EFFECT_KINDS:

Kind Meaning Point-of-use reader
root the free origin; no effect
content_unlock makes a catalog entry (e.g. a defense building) placeable the placement gate
tool unlocks a capability flag has_tool
gate raises a stage / intensity ceiling gate_value
modifier bends a numeric curve tech_modifier

The shipped nodes

Node id Branch Tier Cost (RP) Prereqs Effect
t.root.0 production 0 0 root
t.defense.railgun.1 defense 1 50 t.root.0 content_unlockrail_gun
t.defense.grid.1 defense 2 120 t.defense.railgun.1 content_unlockplanetary_defense_grid
t.exploration.survey.1 exploration 1 30 t.root.0 toolgrid_survey
t.terraforming.hazard_clear.1 terraforming 1 60 t.root.0 toolhazard_clear
t.terraforming.plot_clear.1 terraforming 1 40 t.root.0 toolplot_clear
t.terraforming.intensity.1 terraforming 2 90 t.terraforming.plot_clear.1 gateterraform_intensity (gate 2)
t.production.yield.1 production 1 45 t.root.0 modifierproduction_rate (+0.05)
t.ships.efficiency.1 ships 1 45 t.root.0 modifierturn_cost (−0.05)

The two content_unlock nodes are the live payload: unlocking one makes the matching defense building placeable through the existing build flow. The remaining tool / gate / modifier nodes carry their eventual effect shape so future consumers wire to them without reshaping the catalog; their readers recognize them, but no live game system consults those readers yet.

DAG invariants

assert_dag_reachable() validates the catalog as a well-formed DAG and is safe to call from CI, tests, or startup (pure, no DB):

  1. Exactly one prereq-less root, and it is t.root.0 with cost 0.
  2. Every node's prereqs reference existing node ids; no node lists itself.
  3. No cycles (DFS three-colour detection over the prereq edges).
  4. Every node is reachable from the free root by following its prereq chain.

A catalog edit that strands a node — an unknown branch, an unknown effect kind, a missing prereq, a cycle, or an unreachable node — fails this assertion.

CONTENT_UNLOCK_BY_KEY indexes the content_unlock nodes by the content key they unlock ("rail_gun""t.defense.railgun.1"), and node_id_for_content(content_key) returns the node id that gates a given building type, or None for ungated content.

Point-of-use reads

Downstream systems call these pure readers at the moment they consume a value. Each is a read of the player's ledger against the static catalog — safe to call anywhere, hot-path cheap, and mutating nothing:

  • player_has_tech(player, node_id) -> bool — true iff node_id is in the player's unlocked set.
  • has_tool(player, tool_key) -> bool — true iff the player has unlocked any tool node whose key matches.
  • gate_value(player, gate_key, floor=1) -> int — the highest unlocked gate ceiling for gate_key, else floor. A gate raises a ceiling up, never out.
  • tech_modifier(player, modifier_key, base=0.0) -> float — the summed modifier magnitude for modifier_key, additive on base (e.g. rate = base_rate * (1 + tech_modifier(player, "production_rate"))).

These readers are the only contract a buffed system depends on. They never call into the rest of research, and research is never called from them — the zero-migration guarantee for every buffed system.

Unlocking a node

can_unlock(player, node_id) is a pure read returning {ok, reason}. It fails when the node is unknown, already unlocked, missing prerequisites, or the player lacks the banked RP; on success it also returns rp_cost.

unlock_node(db, player_id, node_id) spends banked RP to unlock a node. It locks the player row, re-checks can_unlock under the lock to prevent a concurrent double-spend, deducts the RP cost, appends the node to unlocked, and flushes. The caller commits (the deduct-and-flush, commit-at-route pattern). On success it returns the remaining RP and the updated unlocked set.

The faucet sweep

The faucet sweep drains each planet's accrued active_events['research_points'] into its owner's ledger. It runs as step 5 of the planetary settle tick (structures.settle()), after production accrual has written the planet's faucet balance. The planet row is already locked by the tick; the sweep acquires the owner's player row in the same transaction (planet-then-player lock order) before touching credits or the ledger.

Per call:

  • Read the planet's active_events['research_points'] (the banked faucet balance).
  • Lock the owning player; lazy-seed a null ledger.
  • Steady state (the player has been swept before, i.e. swept_at is present): add this planet's faucet balance to the ledger's rp, then zero the planet's faucet so RP is never counted twice. RP becomes spendable research currency.
  • First-ever sweep (no swept_at): apply the one-time wipe + refund. The sweep aggregates the faucet balance across every planet the player owns (locking those rows ordered by id, then the player row last — deadlock-safe against the player-facing planet→player build path), zeroes each of those faucets, refunds the summed RP to player.credits at 10 credits per RP, and stamps swept_at once. Pre-tick banked RP is therefore converted to credits rather than dumped on the ledger as a spendable windfall.

The swept_at stamp gates the whole first-sweep path so a re-tick or a restart never double-refunds — the sweep is idempotent. An unowned planet's orphaned faucet is defensively zeroed. The sweep returns whether any state changed, so the caller commits only on a real change.

CRT-4 economy — governor, copay, decay pressure, and directives

The CRT-4 tranche layers a closed-loop economy over the bare faucet-and-unlock kernel: a per-empire governor that compresses runaway RP, a per-day credit copay on banked RP, a per-plot decay-pressure model that creates ongoing demand, and a perishable Research-Directive contract set that sells relief from that demand. Each mechanism ships with a reproduce-exactly off-switch whose off value reproduces today's behavior byte-for-byte, so the live default is neutral — neutral equals today. The mechanisms are dormant under those off-switches and only the magnitudes below turn them on. Every magnitude in this section is NO-CANON, pending Max's ratification; until then the off-switches hold and the loop is a no-op. (lifecycle.md:215 carries the matching NO-CANON flag for the §3.1 net-economy ledger-drain rows.)

Governed RP — the per-empire S-curve soft cap

governed_rp(raw_daily_rp, soft_cap) compresses an empire's raw daily RP above a per-empire soft cap with a logarithmic taper. With excess = raw - soft_cap:

governed = soft_cap + soft_cap × GOV_TAPER × log1p(excess / soft_cap)

Below the cap the function is identity (no excess, no compression); above it, each further unit of raw RP yields progressively less governed RP. The governor is per-EMPIRE — it caps the player's RP aggregated across all their planets, not each planet independently — so a wide empire feels the taper the same as a tall one of equal output.

Constants (all NO-CANON pending Max):

Constant Value Role
GOV_BASE_SOFT_CAP 1500.0 RP/day per-empire, empire-anchored threshold where the taper begins (Max-ruled)
GOV_TAPER 0.5 excess-compression strength (Max-ruled)
GOV_SOFT_CAP_OFF inf reproduce-exactly off value

The off value is GOV_SOFT_CAP_OFF = inf: with soft_cap == inf the excess is never positive and governed_rp(raw, inf) == raw for every input, so the governor is a pure no-op. The governor is neutral until Max blesses a finite cap.

Faucet credit copay

faucet_copay(governed_rp_banked) charges a per-day credit cost to bank RP — a slice of the value the banked RP represents, paid in credits:

copay = FAUCET_CREDIT_COPAY × governed_rp × RP_TO_CREDIT_RATE   (cr/day)

FAUCET_CREDIT_COPAY is the headline E3 number, defaulting to 0.05 (the Orchestrator default), against the same RP_TO_CREDIT_RATE = 10 cr/RP the first-sweep refund uses. The reproduce-exactly off-switch is FAUCET_CREDIT_COPAY = 0.0, which makes the copay zero and debits nothing. Both magnitudes are NO-CANON pending Max.

Decay pressure — per-plot multiplier tiers

decay_pressure() scales the flat per-plot terraform decay rate (TERRA_DECAY_RATE) by a per-plot multiplier keyed to the plot's situation, so a finished peaceful world decays far more slowly than a contested frontier. This is a planet-structures-grid pressure mechanic, tied to the CRT terraform/decay model, and it is the demand engine the directive contracts sell relief from.

The multiplier tiers (all NO-CANON pending Max):

Tier Multiplier Situation
Done 0.1× capstoned, in-band, uncontested
Banded 0.3× in-band, peaceful, not yet capstoned
Frontier 1.0× not-yet-in-band peaceful — the reproduce-exactly off value
Contested 2.0× under siege, recent siege, or claimed border

The reproduce-exactly off value is the Frontier tier at 1.0×: with every plot treated as frontier (the default whenever no planet context is threaded in), the multiplier is uniformly 1.0× and decay matches the shipped flat rate exactly.

A coupled term lets the directive economy feed back into decay: the shared JSONB key terraform_meta.instability is read here as a small additive decay bump (1 + INSTAB_DECAY_COEFF × instability, clamped), so a more unstable world decays faster. An absent or non-positive instability value reads as today's behavior — the bump is neutral until something writes instability, which is what the Stabilize directive bleeds back off. The coefficient and clamp are NO-CANON pending Max.

Terrain mutation

mutate_terrain() mutates a plot's terrain on the planet grid when its terraform axes cross a terrain boundary, re-evaluated each grid tick off the live axes. It is gated by the MUTATION_ENABLED flag, whose off value disables all mutation so terrain never changes — equal to today. The flag is off by default and the boundary thresholds are NO-CANON pending Max.

Research-Directive contracts

Research-Directive contracts are time-boxed, credit-plus-RP-priced directive offers a player buys per-planet. The RP price is the gate (recoverable from the faucet, paces how often a player can buy); the credit price is the sink (the bottomless credit drain). RP is never refunded — a contract cannot re-mint banked RP into anything spendable elsewhere. An empire that buys no contract spends nothing and writes nothing, so an empty contract set is byte-identical to today; that is the reproduce-exactly baseline for this strand.

The kernel contract set (all prices NO-CANON pending Max):

Directive RP (gate) Credits (sink) Effect
Overclock 300 50,000 a temporary production surge on one planet (a multi-day effect that then reverts)
Rush 200 30,000 collapses an in-progress build/terraform timer (instant)
Stabilize 150 20,000 bleeds instability off a contested or slipping world (instant)

Stabilize is the one designed credit↔instability touch-point: on settle it decrements the shared terraform_meta.instability key (clamped at zero), which the decay-pressure reader above consumes as faster-decay relief. At most one active Overclock is allowed per planet so the production surge cannot stack.

Offers are generated, never browsed: the faucet sweep raises at most one perishable offer per empire for a frontier or contested world on a band crossing, rate-limited by a per-empire cooldown, so a healthy or finished empire raises roughly zero per day. An offer perishes free if it expires unaccepted — accepting it is the only spend. ARIA narrates a generated offer and an expired/settled directive as a pure read surfaced when the player looks (narrate_* helpers); ARIA has no proactive emit path, so the narration is a read surface, not a push. Offer-generation magnitudes (expiry window, cooldown) and the offer-generation enable flag are NO-CANON pending Max, with the generation off-switch reproducing today's no-offer baseline.

Source map additions

Concern Path
Governor, faucet copay, directive contracts + offers, ARIA narration services/gameserver/src/services/research_service.py
Decay-pressure tiers, instability decay term, terrain mutation services/gameserver/src/services/structures.py
Planet-grid decay/terraform context services/gameserver/src/services/planetary_service.py
Directive offer + start/cancel routes services/gameserver/src/api/routes/research_cockpit.py

Research-gated defense buildings

The two content_unlock nodes gate the two research-only defense buildings at the existing building-placement path — the single live cross-system dependency the kernel ships:

Building Gate node Effect when locked
Rail gun emplacement t.defense.railgun.1 placement rejected until unlocked
Planetary defense grid t.defense.grid.1 placement rejected until unlocked

A building spec that carries a research_node key is placeable through the existing build flow only if the owning player has unlocked that node. The placement path reads player_has_tech(owner, spec["research_node"]) before the citadel-level and credit checks; on a miss it returns a message naming the required research. This is the point-of-use read in practice — citadel placement calls into research, never the reverse, and no new placement path is introduced. The grid chains off the rail gun (a real Tier-2 prerequisite edge), so a player unlocks rail guns first, then the grid.

The content key planetary_defense_grid is the chosen name for the grid's unlock key, distinct from the unrelated Station.defense_grid boolean.

Invariants

  1. The free root t.root.0 costs 0 RP, has no prereqs, and is the only prereq-less node.
  2. Every cold-start ledger holds t.root.0 in unlocked.
  3. The catalog is an acyclic DAG, every node reachable from the free root (assert_dag_reachable).
  4. Unlocking re-checks affordability and prerequisites under a player row lock before spending.
  5. unlocked only ever grows; banked rp never goes negative.
  6. The faucet sweep zeroes each drained faucet, so RP is never counted twice.
  7. The first-sweep wipe+refund happens at most once per player, gated by swept_at (idempotent across re-ticks and restarts).
  8. Point-of-use readers are pure: they mutate no ledger, no catalog, and no buffed entity.

Source map

Concern Path
Static node catalog + DAG check services/gameserver/src/services/tech_tree.py
Ledger readers, unlock pipeline, faucet sweep services/gameserver/src/services/research_service.py
Ledger column services/gameserver/src/models/player.py (research_ledger)
Settle step 5 (sweep call site) services/gameserver/src/services/structures.py (_step5_research)
Placement gate (defense buildings) services/gameserver/src/services/citadel_service.py (build_defense_building)
Faucet accrual rate RESEARCH_POINTS_PER_LAB_LEVEL_PER_DAY = 25