The server runs dozens of background agents that drive the living world — from enemy health regeneration
and building decay to NPC trade migrations and day/night cycles. These agents are not OS-level processes
or threads; they are SpacetimeDB scheduled tables that
invoke reducer functions at configurable intervals, turning the database itself into a reliable task scheduler.
Every agent — regardless of what it simulates — follows the same five-phase lifecycle.
This uniformity means monitoring, debugging, and load-testing share one toolchain.
AGENT-1.1
Scheduled Table Schema
Each agent is a row in a scheduled_agents table.
SpacetimeDB's built-in scheduler reads next_fire_at and invokes the
bound reducer at exactly that timestamp — no external cron, no process supervisor.
Column
Type
Purpose
{[
{ col: 'agent_id', type: 'Identity', purpose: 'Unique agent instance key. Auto-generated on insert.' },
{ col: 'agent_type', type: 'String', purpose: 'Catalog key: "enemy_regen", "decay_tick", "npc_trade", "day_cycle", etc.' },
{ col: 'target_entity_id', type: 'u64', purpose: 'Optional entity this agent operates on (e.g. enemy_id, building_id).' },
{ col: 'reducer_name', type: 'String', purpose: 'Fully-qualified reducer to invoke: "game.enemy_regen_tick".' },
{ col: 'interval_ms', type: 'u64', purpose: 'Fixed interval in milliseconds. Zero for one-shot agents.' },
{ col: 'next_fire_at', type: 'Duration', purpose: 'Timestamp of next invocation. Scheduler reads this; agent never touches it directly after registration.' },
{ col: 'payload', type: 'JSON', purpose: 'Opaque reducer arguments. Each agent type defines its own schema.' },
{ col: 'state', type: 'enum', purpose: 'active | paused | killed. Only "active" rows are scheduled.' },
{ col: 'generation', type: 'u32', purpose: 'Monotonic counter incremented on each fire. Prevents stale re-schedules.' },
{ col: 'created_at', type: 'Timestamp', purpose: 'Registration time. Used for uptime metrics and TTL enforcement.' },
{ col: 'owner_module', type: 'String', purpose: 'Package that owns this agent: "game" or "global_module".' },
].map((row, i) => (
{row.col}
{row.type}
{row.purpose}
))}
AGENT-1.2
Registration Reducer
register_agent — Pseudocode
// Called by game logic to spawn a new agent reducerregister_agent(ctx, agent_type, target_id, interval_ms, payload) {'{'} // Validate agent_type exists in agent_catalog let catalog_entry = ctx.db.agent_catalog().find(agent_type); require!(catalog_entry.is_some(), "Unknown agent type");
Atomicity guarantee: each agent fire runs inside a single SpacetimeDB transaction.
If the reducer panics or returns an error, the entire transaction rolls back — no partial state mutation.
The agent row is not updated, so the scheduler retries at the same next_fire_at on the next tick.
{[
{ title: 'Read phase', color: 'var(--cyan)', items: [
'Load agent row + payload from scheduled_agents',
'Read target entity state (enemy HP, building condition, NPC inventory)',
'Query related tables (faction relations, player density, market state)',
]},
{ title: 'Mutate phase', color: 'var(--accent)', items: [
'Apply simulation logic (regen HP, decay condition, move NPC, rotate cycle)',
'Write updated entity state back to tables',
'Maybe emit world events, story log entries, or chat notifications',
]},
{ title: 'Re-schedule phase', color: 'var(--green)', items: [
'Increment generation counter (prevents double-fire)',
'Set next_fire_at = now + interval_ms (fixed) or compute next (conditional)',
'Or delete the row entirely (one-shot or TTL expired)',
]},
].map((phase, i) => (
All agents share the same table schema, but when they fire follows one of three
strategies. The choice is baked into the agent's catalog entry and cannot be changed at
runtime without re-registration.
{/* Strategy cards */}
{[
{
id: 'fixed',
title: 'Fixed Interval',
color: 'var(--accent)',
tag: 'Most common',
desc: 'Fire every N milliseconds, unconditionally. The workhorse strategy — used for health regen, resource ticks, decay timers, and day/night cycles.',
mechanics: [
'next_fire_at = last_fire_at + interval_ms — simple arithmetic, no conditional logic.',
'If the server is overloaded and fires late, the next fire is still interval_ms from the actual fire time (not from the scheduled time). Prevents cascade acceleration.',
'Configurable jitter: ±10% of interval_ms to spread simultaneous agent fires across a window. Prevents thundering-herd CPU spikes when 200 enemies share the same regen tick.',
],
examples: 'enemy_regen (2s), building_decay (60s), resource_respawn (300s), day_night_cycle (600s)',
},
{
id: 'conditional',
title: 'Conditional',
color: 'var(--cyan)',
tag: 'Event-gated',
desc: 'Fire only when a precondition table meets criteria. The scheduler still checks on a fixed cadence, but the reducer body is a no-op until the condition is true.',
mechanics: [
'The reducer reads a condition table first (e.g. "has at least one player in system?"). If false, it re-schedules without doing work.',
'Avoids wasting cycles on empty systems: NPC trade migrations only run in systems with active markets and at least one player docked.',
'Condition tables are indexed. The read cost of a no-op conditional fire is ~microseconds.',
],
examples: 'npc_trade_route (fires only if market activity above threshold), faction_tension_eval (fires only if two factions share a border system with standing < -5)',
},
{
id: 'oneshot',
title: 'One-Shot',
color: 'var(--purple)',
tag: 'Delayed execution',
desc: 'Fire exactly once after a delay, then self-delete. Used for delayed consequences, timed explosions, cooldown expirations, and cascading event chains.',
mechanics: [
'interval_ms = 0 in the row. The scheduler fires at next_fire_at and the reducer deletes the row.',
'No re-schedule step — the agent simply ceases to exist after execution.',
'Can be re-registered by the reducer itself or by another agent, creating event chains: "pirate_raid_warning fires → 30s later pirate_raid_arrival fires → raid logic runs".',
],
examples: 'delayed_damage (3s after mine explosion), event_cascade (fires next event in a story chain), cooldown_expire (marks ability ready again)',
},
].map((strategy) => (
{strategy.title}
{strategy.tag}
{strategy.desc}
Mechanics
{strategy.mechanics.map((m, i) =>
{m}
)}
Examples: {strategy.examples}
))}
AGENT-2.1
Jitter & Thundering Herd Prevention
When 500 enemy entities all share a 2-second regen interval, they were likely registered at the same time — meaning
all 500 fire simultaneously. Jitter spreads each agent's next_fire_at by a random ±X% of its interval,
turning a single CPU spike into a smooth load curve.
A single admin-level toggle can pause or terminate every agent of a given type — or all agents
across the entire server. This is the primary operational lever for hotfixes, load shedding,
and emergency maintenance.
Emergency Shutdown
A single reducer kill_all_agents iterates every row
in scheduled_agents and sets state = Killed. The scheduler skips killed
rows entirely. No reducers fire. No CPU. Agents can be selectively revived by type or module.
⬛ KILL ALL
AGENT-3.1
Kill-Switch Mechanics
Operation
Scope
Behavior
{[
{ op: 'kill_all_agents()', scope: 'Server-wide', behavior: 'Sets state=Killed on every row. Scheduler skips all. Agents retain their payload and next_fire_at for potential revival.' },
{ op: 'kill_agents_by_type(type)', scope: 'Type-wide', behavior: 'Kills only agents matching agent_type. Used when one simulation is misbehaving (e.g. enemy_regen running amok) without touching NPC trades.' },
{ op: 'kill_agents_by_module(module)', scope: 'Package-wide', behavior: 'Kills all agents owned by a module ("game" or "global_module"). Allows safe module hot-reload.' },
{ op: 'pause_agent(agent_id)', scope: 'Single agent', behavior: 'Sets state=Paused. Scheduler skips but row persists. Used to suspend a specific NPC\'s trade route.' },
{ op: 'revive_agent(agent_id)', scope: 'Single agent', behavior: 'Sets state=Active, recalculates next_fire_at from now. Agent resumes as if freshly registered.' },
{ op: 'revive_all_by_type(type)', scope: 'Type-wide', behavior: 'Revives all killed/paused agents of a type. Re-applies jitter to prevent synchronized firing.' },
].map((row, i) => (
{row.op}
{row.scope}
{row.behavior}
))}
AGENT-3.2
Safety Properties
{[
{
title: 'No orphan state',
color: 'var(--green)',
desc: 'When an agent is killed mid-execution (transaction aborted), the entity it was modifying rolls back. No enemy is half-healed, no building is half-decayed. The atomicity guarantee ensures killed agents leave zero partial state.',
},
{
title: 'Revival preserves payload',
color: 'var(--cyan)',
desc: 'Killed agents retain their payload, target_entity_id, and interval_ms. Revival simply flips state back to Active and recalculates next_fire_at. The agent continues as if it had paused — no re-registration needed.',
},
{
title: 'Cascade protection',
color: 'var(--accent)',
desc: 'If agent A fires and registers agent B (event chain), killing agent A does NOT kill agent B. Each agent row is independent. Kill operations are explicitly scoped — cascade kills require a custom reducer.',
},
{
title: 'Generation guard',
color: 'var(--purple)',
desc: 'Each fire increments a generation counter. If a kill arrives between the scheduler reading the row and the reducer executing, the generation mismatch aborts the transaction. Prevents zombie fires.',
},
].map((item, i) => (
Complete inventory of every background agent in the server, organized by owning package.
Each entry defines the reducer to invoke, scheduling strategy, interval, concurrency cap,
and what it simulates.