Skip to content

Architecture

RobertMalczyk edited this page Jun 17, 2026 · 3 revisions

Architecture

The engine is a small control system. Each tick: read a frozen snapshot of state, map incoming events to per-channel inputs, run one synchronous update of all states, clamp, then let a selector read the post-update state to choose a visible action. Because the update reads a frozen snapshot, equation order never affects the result — the run is bit-for-bit deterministic.

States = integrators with decay

Every state (anger, stress, frustration, boredom, hunger, fatigue, resentment, …) is one generic integrator: it accumulates its inputs and decays toward rest with a per-state half-life. A "persona" is just a preset of parameters over this shared element — not a separate type. A missing coupling or filter is neutral (0 / identity); wiring is sparse and lives in config.

Channels and filtering

Events become inputs through per-channel filters, not per-event:

  • Relational channels filter per source (who did it) — an insult from a respected superior lands differently than from a stranger.
  • Affinity filters per object (what/where).
  • Physiological inputs (hunger, fatigue) take no filter.

The anger ↔ stress loop

An insult feeds anger directly (a dedicated edge); anger and stress form a coupled 2-cycle, so the lingering anger slowly pulls stress up afterward (stress is the slow accumulator, not an instant jump). With fixed edge gains this loop is linear and always stable — anger spikes and decays.

The outburst overlay (burst & saturation)

An optional, off-by-default overlay turns the linear loop into a stability safety-valve:

  1. Escalation nonlinearity — each loop edge becomes g·(1 + k_esc·other_state), so the local gain grows with the operating point. A single provocation stays stable; only an un-certifiable coincidence of unrelated pressures (insult and hunger and fatigue and a failed search) lifts the operating point past the stability bound → a spiral to the clamp ceiling = a burst.
  2. Burst latch — an integrate-and-fire flip-flop sets only when anger and stress hold above thresholds for several ticks (a plateau, not a spike), with hysteresis on exit.
  3. Extinction — while latched, a slow term drags anger/stress toward zero and dominates the escalated coupling, so the burst self-terminates (bounded).
  4. Displaced discharge — while latched and very angry, the reactive gate opens to the tick's event source (you can lash out at whoever is in front of you), with a refractory edge that softens repeat hits from the same provoker. Sourceless events (weather) never open it — "you cannot kick the rain."

See docs/diagrams/burst_saturation.md for the control + functional diagrams.

Changing the tick rate

The game tick is derived, not set directly:

dt (seconds per tick) = min(half_life) × time_scale ÷ (nyquist_factor × resolution_factor)
decay = 2 ** (−dt / half_life)        # recomputed at config load

All knobs live in calibration/defaults.yaml under tick:. Base config: nyquist_factor: 10, time_scale: 1.0, resolution_factor: 1.0, smallest half-life (anger) = 30 s → dt = 3 s.

There are two safe knobs with different meanings (they compose; both default to 1.0 and are exact no-ops at the default):

time_scalerelabel the clock (bit-identical)

Multiplies every half-life, so dt scales with it and the tick-by-tick trace stays bit-identical — only the seconds a tick represents change (anger half-life 30 s → 20 min at time_scale: 40). This is how the believable-day eval reaches ~120 s/tick. Pick a target with time_scale = dt_target × nyquist_factor ÷ min(half_life) (= dt_target ÷ 3 in the base config): 120 s/tick → 40.

resolution_factorrefine the sampling (real-time preserved)

Shrinks dt by R with the half-lives held fixed, and the loader automatically re-derives every time-dependent coefficient so the real-time trajectory is preserved (more ticks per second = smoother, more event-timing fidelity). Per-kind conversion (spec §2.1): leaks stay exact (2^(−Ts/τ)), continuous rates (drifts, couplings, extinction, idle-recovery, action per-tick effects) scale ×1/R, count windows (*_ticks, cooldowns) scale ×R, event-impulse gains / thresholds / k_esc are invariant. resolution_factor: 8 → 8× finer dt (3 s → 0.375 s), same dynamics.

tick:
  nyquist_factor: 10
  time_scale: 40          # 1.0 = base (3 s/tick); 40 → 120 s/tick (relabel)
  resolution_factor: 8    # 1.0 = base; 8 → 8× finer dt, same real-time behaviour (refine)

Set either per run without editing files (state is dimensionless [0,1], so you can even rebuild the config mid-run and keep ticking):

from engine.yaml_io import load_persona

cfg = load_persona(
    "data/personas/halgrim.yaml", "calibration/defaults.yaml",
    param_overrides={"tick": {"time_scale": 40, "resolution_factor": 8}},  # dt = 15 s
)

A scenario can also carry its own tick.time_scale (read in engine/yaml_io).

Caveats for resolution_factor: leaks are exact but rate terms use forward Euler (O(Ts)), so a finer dt is real-time-faithful in the limit; and the nonlinear/latch logic (escalation, burst confirm-ticks, hysteresis) converges across dt but is not bit-identical in timing. A finer dt you intend to ship is a new operating point — re-verify the boundedness gate before trusting incident counts.

What not to touch: don't retime via nyquist_factor (it changes numerical resolution, so the trace is no longer identical, and below ~10 it risks instability), and don't hand-edit dt or individual half-lives / per-tick numbers for pacing (the loader already converts these — doing it by hand double-scales). time_scale and resolution_factor are the only knobs that keep everything consistent.

Full operator guide (safe-vs-never, verify checklist): docs/HOWTO_change_tick_dt.md in the repo.

Invariants (digest)

  • Synchronous update from one frozen snapshot; clamps after every commit ([0,1], signed [−1,1]).
  • Equations live in exactly one place. State mutates only in update + the selector's small post-effects.
  • Poles of the linearized loops inside the unit circle.
  • Tick dt = min(half-life)/10 (engineering Nyquist).
  • No numeric literal in engine code — everything comes from config; defaults neutral.

LLM (optional, outside the loop)

An LLM is never in the loop and never mutates state. It wires in only at two seams — perception (text → events) and expression (events → text). Without an LLM there is a deterministic fallback. The believability judge is also an LLM, used only to evaluate runs blind, never to drive them.