Tyrell for AI agents

You’ve been asked to integrate Tyrell into a game. This page is the compact, exact version: invariants, a procedure, the contracts of every callback, a skeleton that runs, and the pitfalls with their fixes. For details, follow the links into api.md, integration.md, conditions.md, tuning.md (every setting, and the headline knobs) and the module pages for the modules the game uses.

This describes Tyrell 2.0.0 (save schema 2).

Facts

Invariants

  1. Determinism. The same seed and the same inputs give the same world.hash(). Never use Math.random, Date.now or performance.now in anything the engine calls (conditions, effects, considerations, terms, storylets’ when and fire, Cradle’s namer and template, metrics, custom module hooks). Use world.stream(person), world.worldStream(name) or ctx.world.worldStreamPeek(...). Game code that tests conditions just to look wraps it in world.peek(() => ...), so a chance: there changes nothing.
  2. State is the engine’s. All state is plain JSON in world.s. Game code never writes to it, to a PersonState, or to module state, and never calls engine-side methods (world.mind.setLevel, world.lang.apply, the modules’ apply... methods such as reverie.applyFeel, fellowship.applyRegard or ceres.applyPay, world.ring.claim/release). Every change from the game is an input: a public API mutator (list below) or a custom input (world.defineInput + world.input).
  3. Definitions every start. Needs, places, links, actions, tasks, deeds, module content, custom terms, effects and inputs, listeners, the shell and config are not saved. Declare them on every start, identically, before or after world.load.
  4. People are state. world.person(id, def) adds a person or returns the existing (saved) one, ignoring def. Their PersonDef (routine, job, traits…) is saved with them.
  5. Constant definitions. Don’t change definitions or config by hand during a run; Esper replays with whatever is current. To change settings during play (a slider, a difficulty), use world.tune(module, values), an input kept in the state (tuning.md). config.minutesPerSecond and config.budgetMs are safe to change directly.
  6. Stable order. Custom module code iterates in a stable order.

Public input methods (safe from game code): world.person, fact(key, value), sense, did, report, direct, reward, setVar, give, away, follow, remove, keep, at, log, train, setSkill, wishFor, grant, tune, strangers.promote; reverie.feel, believe, remember, forget, recall, silence; fellowship.adjust, attract, sentiment, changeStanding, join, setPartner; wormtongue.silence, secret, owe, addHeat; cityspeak.say, resolve; gaff.trigger, bump, draw; ceres.pay, rent, income, restock; cradle.birth, pair, depart; athelas.afflict, cure, hasten, tend; delos.set; voice.start, answer, end; bree.start, strike, join, end, witnessed, grudgeAgainst, feud, reconcile; morgul.harm, set, spring, disarm, taint, tend, near.

Procedure

  1. Decide who owns the clock. If the game has its own clock (a day length, sleep that jumps time), call world.advanceTo(gameMinutes) every frame. Otherwise call world.tick(dtSeconds) and read world.hour.
  2. Create the world: Tyrell.create({ seed, start, modules, shell, config, esper, moduleConfig }). Pick only the modules the game uses. Lower config.logSize (300) and size esper to what the game needs; esper: { on: false } if it never rewinds. To change how people behave as a whole (friendships faster, fewer breaks, more gossip), set the headline knobs in config and moduleConfig ({ fellowship: { friendship: 1.5 } }), each 1 by default: tuning.md. A person’s own pace goes in their definition: tune: { reverie: { breaks: 2 } }.
  3. Declare needs. Vital needs (hunger, energy) with urgent at most 1 (default 0.85); soft needs (fun, company, hygiene) with urgent: 1.1 so they never break a routine.
  4. Declare places, each with a region (one per loaded level; omit for a single map), tags, hours, slots, owner, and the game’s coordinates in data. Then travel: world.link(a, b, minutes) between outermost places (links on nested places are ignored; nested places are reached for free), or shell.travel from the game’s pathfinder (cached, in minutes matching walking speed).
  5. Declare actions (and tasks). Free-choice actions need relieves or base. Work actions get job and rank: -1. Sleep gets a night consideration and rates. Give actions label and pose.
  6. Declare module content: deeds, groups, crimes, feelings, lines, storylets, goods; skills (world.skill), pastimes and wishes (world.pastime, world.wish), production chains (ceres.chain), courses (athelas.course), genes and blends (delos.gene, trait, talent, instinct; before anyone is added), talks (voice.approach, thread, promise), and interactions between people (bree.kind, topic, manner, or the starter set, bree.starter(); map Bree’s trait roles to the game’s in moduleConfig.bree.traits), and harm (morgul.means, wound, trap, with their Athelas courses; moduleConfig.morgul.might and crimes).
  7. Add people with world.person(id, def): home, routine (entries covering the whole day), job, traits, values, money, groups, data for the sprite.
  8. One-time setup inputs (starting money in purses, incomes, rent, partners, secrets) guarded with if (!world.kept('setup')) { ...; world.keep('setup', true); }.
  9. Load a save if there is one: world.load(saved).
  10. Wire bodies: world.focus([level]) on start and on every level change (or shell.near); listeners for embody, step, disembody; position walkers from travel.start/travel.end and world.now; refresh a body from world.body(id) when world.now >= body.end.
  11. Wire reports from the game: world.sense({ kind, actor: 'player', what, place, seenBy }) with seenBy from the game’s geometry; world.did('player', verb, { for: id, what }); world.report(id, 'failed', why) when a body truly can’t proceed (the action is then ruled out at that place for config.backoff minutes; if the obstacle lasts, change something, such as a closed:<id> fact, so it isn’t tried again later).
  12. Wire talk: world.cityspeak.choose / say, the bark event; for the player’s conversations, world.voice.start(id), then show view.lines and view.answers and call voice.answer(i) with the choice (each returns the next view); for what passes between people (Bree), show each bree:turn as it comes (turn.text, or the game’s words for turn.line); Joi only if asked for.
  13. Optionally, tell what happened with Irulan (tools.md): world.irulan.day, threads, recap, feed, recount. Give actions, feelings and wishes a tell, show beats from their parts, and keep entries from the log events (or set config.storySize).
  14. Save with world.save() inside the game’s save; restore with world.load. Keep game state that must rewind with world.keep.
  15. Test: run Tyrell.VoightKampff.run({ build: (seed) => buildWorld(seed), days: 7 }) and require passed (in CI too).

Skeleton

A complete, runnable sketch of steps 1 to 12 and 14, with stand-ins for the game’s side. It runs as is in Node.

const Tyrell = require('./vendor/tycho/dist/tyrell.cjs');   // the browser: the global Tyrell

// ---- the game's side: stand-ins -----------------------------------------------
const game = { minutes: 6 * 60, level: 'cafe', save: null };
const DOORS = { apartment: 40, cafe: 300, street: 150 };   // each place's door, x on the street
const WALK = 4;                                       // px per game minute
const routeMinutes = (a, b) => Math.abs(DOORS[a] - DOORS[b]) / WALK;
const sprites = new Map();

// ---- 1-2. create -----------------------------------------------------------------
const travelCache = new Map();
const world = Tyrell.create({
  seed: 'my-game',
  start: game.minutes,
  modules: ['reverie', 'fellowship', 'wormtongue', 'cityspeak', 'mentat', 'ceres'],
  shell: {
    travel(a, b) {                                    // pure, cached, minutes matching walking speed
      const k = a + '>' + b;
      if (!travelCache.has(k)) travelCache.set(k, routeMinutes(a, b));
      return travelCache.get(k);
    },
  },
  config: { logSize: 300 },
});

// ---- 3. needs ------------------------------------------------------------------------
world.need({ id: 'hunger', rise: 0.06, start: 0.3 });
world.need({ id: 'energy', rise: 0.05, start: 0.2 });
world.need({ id: 'company', rise: 0.04, urgent: 1.1 });

// ---- 4. places and regions -----------------------------------------------------------
world.place({ id: 'street', region: 'street' });
world.place({ id: 'apartment', region: 'apartment', tags: ['home'], slots: { bed: 2 } });
world.place({ id: 'cafe', region: 'cafe', tags: ['food', 'social'], hours: [7, 22], slots: { seat: 6 }, owner: 'mo',
  data: { spots: { seat: [20, 40, 60, 80, 100, 120] } } });

// ---- 5. actions ------------------------------------------------------------------------
const night = { name: 'night', input: 'hour', range: [0, 24], curve: [[0, 1], [6 / 24, 1], [8 / 24, 0.05], [21 / 24, 0.3], [23 / 24, 1], [1, 1]] };
world.action({ id: 'sleep', at: 'home', slot: 'bed', minutes: [420, 480], relieves: { energy: 0.95 }, rates: { hunger: 0.4 }, tags: ['sleep'], pose: 'sleep', considerations: [night] });
world.action({ id: 'eat', label: 'eating', at: 'tag:food', slot: 'seat', minutes: 30, relieves: { hunger: 0.6, company: 0.2 }, price: 5, pose: 'sit', tags: ['social'] });
world.action({ id: 'serve', at: 'cafe', minutes: 60, job: 'cook', rank: -1, pose: 'work' });
world.action({ id: 'loaf', at: 'home', minutes: 45, base: 0.03, pose: 'sit' });

// ---- 6. module content -----------------------------------------------------------------
world.deed('helped', { feeling: 'helped', regard: 0.15, memory: 5 });
world.wormtongue.crime({ kind: 'theft', severity: 0.4 });
world.cityspeak.line({ key: 'hello', topic: 'greet', text: 'Hello.' });

// ---- 7. people ---------------------------------------------------------------------------
world.person('mo', { name: 'Mo', home: 'apartment', vars: { money: 40 },
  job: { id: 'cook', place: 'cafe', action: 'serve', shifts: [[7, 15]] },
  routine: [[6, null, '*'], [22, 'home', 'sleep']] });
world.person('ana', { name: 'Ana', home: 'apartment', vars: { money: 30 }, routine: [[7, null, '*'], [23, 'home', 'sleep']] });

// ---- 8. one-time setup, guarded --------------------------------------------------------------
if (!world.kept('setup')) { world.ceres.income('ana', 50, 7); world.keep('setup', true); }

// ---- 9. load -------------------------------------------------------------------------------------
if (game.save) world.load(game.save);

// ---- 10. bodies --------------------------------------------------------------------------------
world.on('embody', (b) => sprites.set(b.id, { body: b }));
world.on('step', (b) => { const s = sprites.get(b.id); if (s) s.body = b; });
world.on('disembody', ({ id }) => sprites.delete(id));
world.focus([game.level]);

function frame(dtSeconds) {
  game.minutes += dtSeconds * 2;                      // the game's clock: 2 game minutes a real second
  world.advanceTo(game.minutes);                      // or, if Tyrell owns the clock: world.tick(dtSeconds)
  for (const [id, s] of sprites) if (world.now >= s.body.end) s.body = world.body(id) ?? s.body;
}

// ---- 11. what the game saw ---------------------------------------------------------------------
const playerHelped = (id) => world.did('player', 'helped', { for: id });
const playerStole = (place, seenBy) => world.sense({ kind: 'theft', actor: 'player', place, seenBy });

// ---- 13. saving ------------------------------------------------------------------------------------
const saveGame = () => { game.save = JSON.stringify(world.save()); };

for (let i = 0; i < 60 * 60 * 3; i++) frame(1 / 60);   // three real minutes: six game hours
playerHelped('ana');
saveGame();
console.log(world.describe('mo'));

Contracts

Tyrell.create(opts): { seed?, start?, modules?, shell?, config?, esper?, moduleConfig?, extra? }. Unknown module names throw. Defaults: api.md; every setting: tuning.md.

world.tune(module, values | null, person?): an input. module is a switched-on module’s name or 'core'; values are fields of its settings (the setting must exist; the value plain data of the same kind as the default), null for a field puts it back, null for values the whole module. With person, only that module’s per-person headline knobs, numbers 0 or more (written to the person’s tune). Throws, recording nothing, on anything else. world.tuning(module, person?) reads what’s in force.

shell.travel(from, to, person?) => number: minutes. 0 when no walk is needed, Infinity when unreachable. Deterministic and pure given Tyrell’s state (it may read facts, not game state that doesn’t rewind). Called for any two different place ids (including a place and one nested inside it), very often, and during replays; not cached by Tyrell. Must agree with how fast sprites walk.

shell.near(body, world) => boolean: whether the person gets a body. Called for every active person on every refresh (each tick). May read the game’s view (it never affects the simulation). With it, focus() no longer decides who’s near.

shell.check(fact, ctx) => Value | undefined: answers fact:<key> when the fact isn’t set. Live, not rewound: prefer pushing world.fact(key, value).

Listeners (world.on(kind, fn), returns an unsubscribe function): run synchronously inside the engine’s work. Inputs made inside are deferred to the end of the current event or input (and recorded there); world.input returns undefined for them. Inputs that return a value (world.person, strangers.promote, reverie.recall, cityspeak.say, cityspeak.resolve, gaff.draw, cradle.birth) apply at once. tick, advance, advanceTo, seek, rewind, esper.seekMark and load called from a listener wait until the call that’s running returns, then run in order. Don’t throw. A '*' listener gets { kind, ...payload, event }: read the name from event (kind is the payload’s own for witnessed, report and broke). Event list: api.md.

Bodies: { id, name, place, slot?, action, next?, pose, target?, progress, start, end, travel?: { from, to, start, end, f }, emotion?, mood?, with?, data? }. place is null while walking. action is 'travel' while walking and 'wait' while idle. progress and travel.f are as of when the body was made: recompute from world.now.

world.report(id, 'failed' | 'done', reason?): 'failed' stops the walk (ending at to past halfway, else from) or action (partial relief), emits failed, rules the action out at that place for config.backoff (15) minutes, replans a plan, and makes them think. More than ten failures for one person at one moment make them wait config.idle minutes instead, so a listener that fails every step can’t loop. 'done' ends it now with full effect. Both inputs.

Custom input: world.defineInput(name, (...args) => result), defined on every start before loading or seeking. Call with world.input(name, argsArray). Arguments plain JSON. The handler may change state directly.

Custom term: world.term(name, (ctx, arg) => Value | undefined): pure and deterministic. arg is the text after the first . or :.

Custom effect: world.effect(name, (ctx, arg, op, value) => void): runs inside the simulation; may change state directly; deterministic. op is '=', '+=', '-=', '*=' or ''.

Function conditions, effects, consideration inputs: (ctx) => boolean, (ctx) => void, (ctx) => number, with ctx = { world, person?, place?, target?, action?, topic?, data? }. Not allowed in routines (saved data): strings only there.

Storylet fire(world, { place, subject, id }): runs inside world.internally() (its inputs aren’t recorded; a replay runs it again). Deterministic; use the public API.

Cradle namer(r, parents) => string, template(r, parents) => PersonDef: deterministic, using the stream r.

Joi provider (req) => Promise<string | { text, flags? }>: req = { prompt, system, who, to?, line }. Times out after 2500 ms by default and falls back to the written line.

Module (custom, via create({ extra })): { name, attach(w), init?(), loaded?(), candidates?, consider?, block?, rate?, started?, ended?, arrived?, met?, hour?, day?, timer?, sensed?, did?, added?, removed?, failed? }. State in w.s.mod[name] from init() (or made on first use, so a module nobody uses leaves the state as it was). rate(p, need) multiplies how fast a need rises: call w.mind.settleRise(p) before its answer changes. See api.md.

Morgul’s harm (morgul.harm(h)): { to, by?, kind?, severity?, via?, intent?, cause?, what?, place?, seenBy?, course? }, an input that applies at once and returns the harm (HarmView) or null. seenBy from the game’s sight; the default is everyone at the place. Nobody dies of harm unless lethality is above 0 (and Cradle is on). Call morgul.near(regions) with focus (or let the bridge) so traps where the player is are left to the game’s triggers; it’s recorded, so rewinds replay it.

Bree’s definitions: kinds’, topics’ and manners’ functions (weight, turns, lands, knack, fits, fit), bree.witnesses(exchange, world) => ids (who sees one go wrong) and bree.together(exchange, world) => id | null (who has left it): deterministic, from the world’s state, set on every start. Exchanges need the two together, doing something at the same place: Bree never moves anyone. A game that works out who’s near whom itself (its people each at a place of their own) gives bree.together, and strikes exchanges up with bree.strike(a, b) as it walks one up to another.

Voice’s definitions voice.warmth(ctx) and voice.patience(ctx): deterministic numbers (a talk’s starting warmth and its beats), and voice.remembered(ctx, key, value, form), the words for what’s been learned ({learned.<key>:<form>}), set on every start like Cradle’s namer. What the speaker has learned and how familiar they are can count: familiarLand (trust in a talk), answers and threads that recalls a learned key, Wormtongue’s familiarReport; voice.acquaintances() lists everyone the speaker knows. Approaches’ lands, threads’ weight and label, wishes’ about and progress functions: deterministic, from ctx.

Pitfalls

Pitfall Symptom Fix
A routine with one entry, or a gap you meant as “until noon” The entry lasts all day: each entry runs until the next one, and the last until the first entry of the next day Write the whole day: [[7, null, '*'], [12, 'cafe', 'eat'], [13, null, '*'], [23, 'home', 'sleep']]
'*' with a place vs with null [h, null, '*'] is free time; [h, 'home', '*'] requires “anything offered at home”, which includes actions with no at and rank: -1 actions (a sulk, a bender), at near-even odds Use null for free time. With a place, keep “only when required” actions out of it (who/if), or name a specific activity
urgent left at the default for soft needs Fun or company pulls people off their routine urgent: 1.1 for needs that should never break a routine
urgent above 1 for vital needs Hunger never breaks a routine or a shift, so people with full days go hungry (and Voight-Kampff’s nobody-starves only checks needs with urgent <= 1, so it won’t notice) Keep hunger and energy at the default
Work action without job Staff are turned away when their shift starts before opening hours (“closed then”) Give work actions job: '<job id>' (only actions, not tasks, get in when closed)
Work action without rank: -1 Off-shift workers do it in their free time rank: -1: only when required
An action with neither relieves nor base Never chosen freely Add base, or require it (routine, job, directive)
Expecting an input from a listener to apply at once The state hasn’t changed yet; world.input returned undefined Inputs in listeners are deferred to the end of the event; react to the next event instead
Writing world.s or calling engine methods from game code Works until a rewind or replay, then the world diverges Use an input; for anything missing, world.defineInput
Shell travel times that don’t match walking speed Sprites arrive before or after Tyrell’s timetable; near and far disagree Return the route length divided by walking speed, or place walkers by travel.f
Setup inputs (ceres.pay, income, rent, setPartner, secret) on every start Repeated on top of a loaded save Guard with world.kept('setup') and world.keep('setup', true)
Changing a person’s routine in code Saved people keep their saved definition A custom input that patches world.get(id).def, run once per content version (saving.md)
Changing world.config or a module’s config from an options screen Lost on load, missing after a rewind, and replays differ world.tune(module, values): recorded, kept in the state
Expecting world.reverie.config (or world.config) to show a tuned value They’re what was declared world.tuning(module) (or world.cfg for the core)
money in conditions with a household purse Reads 0 for household members Use purse (Ceres)
An action that buys and uses the same good Ruled out: uses is checked before buying Price plus relieves for on-the-spot eating, or two actions
The default near rule with several small levels A walker shows in both the origin and destination levels for the whole walk shell.near from the route: which level they’re in at travel.f
A task whose methods’ conditions can all fail no-plan, the person idles 20 minutes and picks it again Give the task if conditions, or a last method with none
The game’s clock going backward, or a different epoch advanceTo with a past time rewinds the world (or does nothing with Esper off) Minutes since midnight of day 0, monotonic; rebuild when going back past esper.earliest()
world.save(true) into localStorage Quota errors: history is megabytes Save without history; or IndexedDB; lower logSize and Esper’s keep option
Inputs inside seek/embody handlers after a rewind Each one branches the timeline, dropping the recorded future Read state there; don’t write
world.did data in TypeScript seenBy: string[] isn’t a Value Cast the data object
A directive that’s done It stays on the person, marked done world.direct(id, null) to clear, or use until
A job or routine action that makes goods, left to fill the shift It makes its goods once a shift fill: false: it makes them once per its own minutes, again and again
Delos’s genes and traits declared after the people Founders have no genome (they get one only when a birth needs it) Declare Delos’s definitions before world.person
A wish about a person, granted by an action with no target Never granted: an action must be aimed at them Give the by action target: 'person', or grant it with when
Changing what a module’s rate returns without settling Needs jump, as if the new rate had applied all along world.mind.settleRise(p) first
Bree on, kinds declared, a conversation kind (with topics) that ends at once No topic fits it: it takes its leave Declare topics it fits (or the starter set’s), or kinds without topics
Expecting exchanges between people walking past each other Exchanges are struck up only at meetings (both doing something at a place), and end when one walks on Start a one-turn kind with bree.start as they pass (the game vouches for its first turn), or keep greetings as barks
Expecting voice.answer(i) to work after the talk changed The answers are those of the beat on show Read voice.current() (or the view the last call returned) for the indices
The game keeps log entries for Irulan and the player rewinds The journal tells what no longer happened On the seek event, drop entries past to; or tell from config.storySize’s story, which rewinds with the world
Showing a beat’s text and finding names in it Names in place names (Mara’s Cafe) get linked; renamed people break it Show its parts: people and places come tagged with their ids

Verify

Run these on the game’s world (a build(seed) function that creates and declares everything):

const report = Tyrell.VoightKampff.run({ build: (seed) => buildWorld(seed), days: 7 });
console.assert(report.passed, report.failures.join('\n'));

The default checks cover starvation, slot capacity, sleep, people stuck idle, determinism (same seed, same hash), Esper (back six hours and forward: same hash) and save/load (same hash, and the same three hours later). Also check by hand that: