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
- Browser:
<script src="vendor/tycho/dist/tyrell.js">before the game’s scripts defines the globalTyrell. Node:require('./vendor/tycho/dist/tyrell.cjs')or importdist/tyrell.mjs. Addvendor/tycho/dist/tyrell.jsto a service worker’s cached files. const world = Tyrell.create(opts). Everything else is a method or property ofworld.- Time is game minutes.
world.nowis minutes since midnight of day 0. Hours in definitions are fractional 0 to 24; spans[a, b)wrap past midnight. - Needs are 0 (calm) to 1 (desperate);
riseis per game hour. - Ranks: break 4 > story 3 > desperate need 2.5 to 2.8 > job 2 > urgent need 1.5 > routine 1 > free choice 0. Only the top rank is considered; weight decides within it.
- Places are ids; regions are what the game loads at once; travel is minutes (links or
shell.travel). No coordinates anywhere. - The engine talks to the game only through events (
world.on) and the three optional shell callbacks.
Invariants
- Determinism. The same seed and the same inputs give the same
world.hash(). Never useMath.random,Date.noworperformance.nowin anything the engine calls (conditions, effects, considerations, terms, storylets’whenandfire, Cradle’snamerandtemplate, metrics, custom module hooks). Useworld.stream(person),world.worldStream(name)orctx.world.worldStreamPeek(...). Game code that tests conditions just to look wraps it inworld.peek(() => ...), so achance:there changes nothing. - State is the engine’s. All state is plain JSON in
world.s. Game code never writes to it, to aPersonState, or to module state, and never calls engine-side methods (world.mind.setLevel,world.lang.apply, the modules’apply...methods such asreverie.applyFeel,fellowship.applyRegardorceres.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). - 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. - People are state.
world.person(id, def)adds a person or returns the existing (saved) one, ignoringdef. TheirPersonDef(routine, job, traits…) is saved with them. - 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.minutesPerSecondandconfig.budgetMsare safe to change directly. - 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
- 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 callworld.tick(dtSeconds)and readworld.hour. - Create the world:
Tyrell.create({ seed, start, modules, shell, config, esper, moduleConfig }). Pick only the modules the game uses. Lowerconfig.logSize(300) and sizeesperto 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 inconfigandmoduleConfig({ fellowship: { friendship: 1.5 } }), each 1 by default: tuning.md. A person’s own pace goes in their definition:tune: { reverie: { breaks: 2 } }. - Declare needs. Vital needs (hunger, energy) with
urgentat most 1 (default 0.85); soft needs (fun, company, hygiene) withurgent: 1.1so they never break a routine. - Declare places, each with a
region(one per loaded level; omit for a single map), tags,hours,slots,owner, and the game’s coordinates indata. Then travel:world.link(a, b, minutes)between outermost places (links on nested places are ignored; nested places are reached for free), orshell.travelfrom the game’s pathfinder (cached, in minutes matching walking speed). - Declare actions (and tasks). Free-choice actions need
relievesorbase. Work actions getjobandrank: -1. Sleep gets a night consideration andrates. Give actionslabelandpose. - 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 inmoduleConfig.bree.traits), and harm (morgul.means,wound,trap, with their Athelas courses;moduleConfig.morgul.mightandcrimes). - Add people with
world.person(id, def): home, routine (entries covering the whole day), job, traits, values, money, groups,datafor the sprite. - One-time setup inputs (starting money in purses, incomes, rent, partners, secrets) guarded with
if (!world.kept('setup')) { ...; world.keep('setup', true); }. - Load a save if there is one:
world.load(saved). - Wire bodies:
world.focus([level])on start and on every level change (orshell.near); listeners forembody,step,disembody; position walkers fromtravel.start/travel.endandworld.now; refresh a body fromworld.body(id)whenworld.now >= body.end. - Wire reports from the game:
world.sense({ kind, actor: 'player', what, place, seenBy })withseenByfrom 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 forconfig.backoffminutes; if the obstacle lasts, change something, such as aclosed:<id>fact, so it isn’t tried again later). - Wire talk:
world.cityspeak.choose/say, thebarkevent; for the player’s conversations,world.voice.start(id), then showview.linesandview.answersand callvoice.answer(i)with the choice (each returns the next view); for what passes between people (Bree), show eachbree:turnas it comes (turn.text, or the game’s words forturn.line); Joi only if asked for. - Optionally, tell what happened with Irulan (tools.md):
world.irulan.day,threads,recap,feed,recount. Give actions, feelings and wishes atell, show beats from theirparts, and keep entries from thelogevents (or setconfig.storySize). - Save with
world.save()inside the game’s save; restore withworld.load. Keep game state that must rewind withworld.keep. - Test: run
Tyrell.VoightKampff.run({ build: (seed) => buildWorld(seed), days: 7 })and requirepassed(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:
- a run with
focuson the loaded regions and a run with nothing focused give the sameworld.hash()after the same inputs (watching changes nothing); world.whyNot(id, action)explains anything surprising before you change weights;world.describe(id)reads sensibly for a few people at a few times of day.