Integration

Everything a game does to run Tyrell: the shell, regions and focus, bodies, the clock, reporting what happened, talking, saving. It ends with notes for two common layouts and a checklist.

What the game does

The game… With
Declares the world on every start world.need, place, link, action, task, deed, module declarations
Adds people (or loads them) world.person, world.load
Moves time world.tick or world.advanceTo
Says what’s on screen world.focus or shell.near
Draws and walks bodies embody, step, disembody events; world.bodies(), world.body(id)
Says when a body couldn’t do something world.report
Says what was seen and done world.sense, world.did
Asks people what they’d say cityspeak.choose, cityspeak.say, bark events
Steers the story world.direct, world.fact, Gaff storylets
Saves and loads world.save, world.load, world.keep

The shell

The shell is three optional callbacks the engine asks the game. Pass them at creation:

const world = Tyrell.create({
  seed: 'my-town',
  shell: {
    travel: (from, to, person) => pathMinutes(from, to),
    near: (body, world) => isOnScreen(body),
    check: (fact, ctx) => (fact === 'lights' ? game.lightsOn : undefined),
  },
});
Callback Asked Return Default
travel(from, to, person?) Whenever a travel time is needed: weighing options (every candidate place), starting walks, strangers Minutes; 0 for no walk; Infinity for unreachable Shortest path over world.link links
near(body, world) For every active person, every time bodies are refreshed (each tick, advance, seek, focus, load) true if the person should have a body In a focused region (or walking from or to one)
check(fact, ctx) When a fact:<key> condition finds no fact set A value, or undefined Nothing (null)

Contracts:

Regions and focus

A region is what the game loads at once: a level, a district, the whole map. Each place has one (region on the place, or its parent’s, or 'world').

world.place({ id: 'noodles', region: 'd_noodles', tags: ['food'], slots: { stool: 7 } });
world.place({ id: 'grocery', region: 'd_grocery', tags: ['shop'] });

world.focus(['d_noodles']);                 // the player walked into the noodle bar

world.focus(regions) says which regions are loaded. With the default near rule, a person is near when their place is in a focused region, or when they’re walking from or to one. Focus isn’t saved and isn’t part of the state: call it on start, after a load (in a new world), and whenever the loaded level changes. Calling it refreshes bodies at once.

Bodies

A body is a person as the game should show them now. The game gets bodies from events, and can ask for them any time:

const sprites = new Map();
world.on('embody', (body) => sprites.set(body.id, makeSprite(body)));
world.on('step', (body) => { const s = sprites.get(body.id); if (s) s.body = body; });
world.on('disembody', ({ id }) => { destroySprite(sprites.get(id)); sprites.delete(id); });
world.on('arrive', ({ id, place }) => { /* anyone, near or far, got somewhere */ });

world.bodies();       // everyone near now
world.body('ruth');   // anyone's body, near or not (null if they're away or gone)
Event Payload When
embody Body Someone comes into view: on focus, after time moves, after a seek or a load. They may be mid-walk or mid-meal
step Body A near person starts something: sets out, starts an action, starts waiting (a plan’s wait, or nothing to do), follows a leader; or, after a backward seek, is doing something else than before it
disembody { id } Someone leaves view: walked out of the focused regions, went away, left for good, wasn’t in view at the time a seek went to, or every body before a load
arrive { id, place } Anyone arrives anywhere, near or far
failed { id, why } Anyone’s action or walk failed (a closed door, a taken stool, a report)

Listeners run in the middle of the engine’s work. Inputs they make (a report, a fact) wait until the engine finishes the event it’s handling. Calls that move time or replace the state (tick, advanceTo, seek, load…) made from a listener wait until the call that’s running returns, then run.

Someone who finishes and has nothing to do waits (action: 'wait'), and that comes as a step too. Bodies are made when they’re sent, so to keep progress current, recompute it from world.now, or read world.body(id) every frame for the people on screen.

Body fields

Field Meaning
id The person’s id
name Their name
place Where they are; null while walking
slot The slot they hold, if any ('stool')
action The action id; 'travel' while walking; 'wait' while waiting or idle
next While walking: the action they’re walking to do ('@go:<place>' for a plan’s bare walk)
pose The action’s pose; 'walk' while walking; 'idle' while waiting or for actions without a pose
target The person the action is aimed at
progress 0..1 through the current action or walk, when the body was made
start, end When it started and ends, in minutes
travel While walking: { from, to, start, end, f }, with f the fraction walked when the body was made
emotion, mood With Reverie: the emotion showing and the mood, for faces and posture
with The person the routine entry names in with (a hint for walking side by side)
data The person’s PersonDef.data (a look, a sprite id)

To know the layer (job, routine, need…), read world.get(id).doing.layer.

Mapping place and slot to coordinates

Tyrell has no coordinates; keep them in the place’s data and pick a spot per slot. world.ring.holders(place, slot) lists who holds a slot in claim order, but that order shifts as people leave, so assign each person a spot when they start and keep it:

world.place({
  id: 'noodles', region: 'd_noodles', slots: { stool: 4 },
  data: { spots: { stool: [120, 150, 180, 210], default: [300, 330] } },
});

const seats = new Map();   // person id -> { place, slot, i }
function spotFor(body) {
  const spots = world.ring.get(body.place)?.data?.spots ?? {};
  const list = spots[body.slot ?? 'default'] ?? spots.default ?? [0];
  let s = seats.get(body.id);
  if (!s || s.place !== body.place || s.slot !== body.slot) {
    const taken = new Set([...seats.values()].filter((v) => v.place === body.place && v.slot === body.slot).map((v) => v.i));
    let i = 0;
    while (taken.has(i) && i < list.length - 1) i++;
    s = { place: body.place, slot: body.slot, i };
    seats.set(body.id, s);
  }
  return list[s.i];
}
world.on('disembody', ({ id }) => seats.delete(id));

Walking along a route

While walking, a body has travel: { from, to, start, end, f }. The walk takes exactly end - start game minutes whether or not anyone watches. The simplest way to keep near and far in agreement is to place the sprite from the fraction each frame:

function walkerPosition(body) {
  const tr = body.travel;
  const f = Math.min(1, Math.max(0, (world.now - tr.start) / (tr.end - tr.start)));
  const path = myRoute(tr.from, tr.to);          // the game's path, as points
  return pointAlong(path, f);                    // so the sprite arrives at tr.end
}

If your sprites walk at their own speed instead, the shell’s travel times must match that speed, or sprites arrive before or after Tyrell says they do. Arriving early is harmless (stand at the door until the step for the action comes); arriving late means a snap. Don’t call world.report(id, 'done') to make an on-screen walk end early: the arrival would then differ from the one an unwatched world would have.

Hand-over mid-action

There’s nothing to hand back. When someone comes into view, embody gives their body as it is now: halfway along a walk (travel.f), or forty minutes into a two-hour drink (progress). Place the sprite with the same functions as above. When they leave view, disembody tells you to remove the sprite, and Tyrell carries on with the same timetable.

Reporting back from a body

When a body can’t do what Tyrell asked, say so:

world.report('ruth', 'failed', 'the door is locked');   // she stops, and thinks again
world.report('ruth', 'done');                           // she's finished early
Report Effect
'failed' The current walk or action stops. A walk ends at the place they’re nearest (to past halfway, else from); an action keeps the relief it gave so far. The failed event and log entry; the action is ruled out at that place for config.backoff minutes (15); a plan replans, and they think again
'done' The current walk or action ends now, as if it ran its course: a walk arrives, an action gives its full relief and effects

Both are inputs: a report made inside a step listener is deferred to the end of that event and recorded there.

A failure backs off: the action that failed is ruled out at that place for config.backoff minutes (15 by default), so the person chooses something else (the inspector says failed recently), and may try it again after that. Someone who fails over and over at one moment (a listener that reports a failure for every step) stops choosing after ten failures and waits config.idle minutes instead. So a failure can’t loop, but an obstacle that stays makes them fail again each time the back-off runs out. Fail only when the obstacle is real, and when it lasts, change something (a fact, a closed place) so nobody chooses it:

world.fact('closed:vault', true);          // now nobody chooses it
world.report('ruth', 'failed', 'locked');

Who owns the clock

Tyrell owns it. Call world.tick(seconds) every frame. The game reads the time from Tyrell.

let last = performance.now();
function frame(now) {
  const dt = Math.min(0.25, (now - last) / 1000);
  last = now;
  if (!paused) world.tick(dt);       // minutesPerSecond × speed game minutes per second
  drawDayNight(world.hour);
  drawBodies();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

config.minutesPerSecond sets the pace (1 by default: a game day is 24 real minutes); world.speed scales it (0 pauses, negative plays backward with Esper). tick has a frame budget (config.budgetMs, 4 ms): on a heavy frame time runs a little slow instead of stuttering. Sleeping or a cut scene: world.advance(minutes).

The game owns it. Games with their own clock (a day of N real seconds, a dev slider, sleep that jumps the clock) call world.advanceTo with the game’s time in minutes every frame:

function gameMinutes() { return (game.day * 24 + game.hour) * 60; }   // minutes since day 0, midnight

function syncTyrell() {
  const t = gameMinutes();
  if (t < world.esper.earliest()) rebuildWorld(t);   // further back than time walk remembers
  else if (t !== world.now) world.advanceTo(t);      // forward, or back with Esper
}

advanceTo has no frame budget, so a big jump runs at once. A time in the past seeks back with Esper (or does nothing if Esper is off), so make sure the game’s clock never goes backward by accident, and uses the same epoch: minute 0 is midnight on day 0.

Either way, read world.now, world.hour, world.day and world.weekday for the time.

Sensing and deeds

Tyrell never decides who saw what. The game knows where everyone is on screen, so it reports what was perceived and by whom.

// the player stripped a till; the game works out who could see it
const seenBy = world.bodies()
  .filter((b) => b.place === 'grocery' && canSee(sprites.get(b.id), player))
  .map((b) => b.id);
world.sense({ kind: 'strip', actor: 'player', what: 'till', place: 'grocery', seenBy, strength: 0.8 });

Stimulus:

Field Default Meaning
kind required What happened: 'strip', 'theft', 'fight', 'shout'. With Wormtongue, a kind declared as a crime is treated as one
actor required Who did it ('player' for the player)
what none What it was done to or with ('till'). It becomes the value of the witnesses’ belief
place none Where
seenBy everyone at place except the actor Who perceived it. Only people doing something at the place count by default, not people walking past
strength 1 How clearly, 0..1
disguise 0 How well the actor was disguised, 0..1
severity the crime’s How bad it was, 0..1, for a crime (Wormtongue): the witnesses’ memories, and the heat a report adds. Morgul passes a harm’s
data none Anything else

Off screen, people commit crimes too (actions with crime), and everyone at the place may witness them. What happens next (beliefs, memories, reports, heat, gossip) is Wormtongue.

Harm (a blow, a trap sprung, a dose eaten) has its own report: world.morgul.harm({ to, by, kind, severity, via, intent, seenBy }), and world.morgul.spring(trap, id) for a trap the game’s trigger caught. What follows (the wound, the grudge, the fear, the crime, suspicion) is Morgul.

A deed is something someone did to or for someone else. Declare what each verb means, then report deeds as they happen:

world.deed('fixed', { feeling: 'helped', regard: 0.2, memory: 6, sentiment: 'fixed my radio', fame: 0.05 });
world.deed('cheated', { feeling: 'wronged', regard: -0.3, memory: 7, infamy: 0.1, crime: 'fraud' });

world.did('player', 'fixed', { for: 'ruth', what: 'radio' });
world.did('player', 'cheated', { for: 'dev', what: 'change', place: 'market', seenBy: ['ruth'] });

DeedDef:

Field Module Meaning
feeling Reverie A feeling for the person it was done to
witnessFeeling Reverie A feeling for anyone in seenBy
memory Reverie The importance (1..10) of the memory it leaves the receiver
regard Fellowship Change in the receiver’s regard for the doer (witnesses get 0.4 of it)
sentiment Fellowship A sentiment the receiver keeps about the doer (worth regard, or 0.1)
fame, infamy Fellowship Change in the doer’s standing with each of the receiver’s groups
crime Wormtongue Treat it as this crime when witnessed (needs seenBy)

The data of world.did: the receiver is for (or to, or target); what, place and seenBy (an array of ids) are read by the modules; anything else is logged. Every deed is noted in the receiver’s file on the doer (and the witnesses’), which conditions read as file:player.fixed. Actions with deed and a target report the same way when they end. In TypeScript, seenBy needs a cast, since data is typed as plain values.

world.westmarch.chronicle(id, since) lists deeds done for someone whichever of for, to or target names them.

Talking

People’s lines are Cityspeak. The game owns the words: lines carry a key (and optionally text), and the most specific line that matches the moment wins.

const line = world.cityspeak.choose('mara', { topic: 'greet', to: 'player' });   // look only
const said = world.cityspeak.say('mara', { topic: 'greet', to: 'player' });      // say it
if (said) showBubble(said.who, said.text ?? myStrings[said.key]);

world.on('bark', ({ a, b, lines }) => {
  for (const l of lines) setTimeout(() => showBubble(l.who, l.text ?? myStrings[l.key]), l.after * 1000);
});

choose changes nothing; say is an input: it remembers the line was said (for once and cooldown), applies the line’s then effects, and emits say. Barks are short exchanges between two people who meet while one of them is near; they never change the world. For a language-model voice, see Joi.

For conversations the player takes part in, with choices, use Voice: a talk of threads and beats whose words are the same Cityspeak lines, chosen by topic:

let view = world.voice.start('ruth');                 // an input: recorded, replayed
show(view.lines, view.answers);                       // { who, key, text } lines; answers with i, kind, label or say, ok
view = world.voice.answer(chosen.i);                  // the next beat (an input too)

For what passes between the people themselves, on screen and off, use Bree: exchanges that come out by degrees, conversations over turns on topics in each one’s manner, quarrels that escalate, grudges and making up. They strike them up when they meet; the game shows each turn as it comes:

world.bree.starter();                                 // or declare your own kinds, topics and manners
world.on('bree:turn', (t) => showBubble(t.who, t.text ?? myStrings[t.line]));
world.on('bree:escalated', (e) => shake(e.by));

Directing the story

world.direct('vance', { do: 'patrol', at: 'market', until: world.now + 120 });
world.direct('vance', null);
world.fact('festival', true);
world.gaff.trigger('blackout');

Directives outrank jobs and routines (see Deciding); facts change what conditions see; Gaff’s storylets pace events on their own (see Gaff).

Telling what happened

Irulan, the narrator (Tools), turns the log into sentences the game can show: an inspector’s “their day so far” (world.irulan.day(id, since)), a journal of storylines (threads), a recap after time away (recap), a notice when something that matters happens (feed), and what someone says when asked what’s new (recount, in their own words, only what they know).

const kept = [];
world.on('log', (e) => kept.push(e));                 // or config.storySize, to keep what matters in the world's state
world.on('seek', (e) => { if (e.to < e.from) kept.splice(0, kept.length, ...kept.filter((x) => x.t <= e.to)); });
const stop = world.irulan.feed((beat) => toast(beat.parts), { min: 0.7, focus: { [playerFriend]: 2 } });
journal.show(world.irulan.threads(kept).threads);     // { id, title, parts, people, beats, active }
npc.say(world.irulan.recount(npcId, kept, { limit: 2 }).map((b) => b.text));

Show sentences from their parts, not their text: each person and place in them comes tagged with its id, for links, portraits and colors. A beat’s with is who took part in it and around who was only there. Give the town its own words with each definition’s tell (an action’s, a feeling’s, a wish’s) and world.irulan.words(kind, fn). If the game keeps its own entries, drop those past the time gone back to on a rewind, as above; the log and the story rewind with the world on their own.

Custom inputs

Some changes have no ready-made input: setting a need when the player feeds someone, changing a person’s definition. Define an input, and call it through world.input, so Esper records and replays it:

world.defineInput('feed', (id, amount) => {
  const p = world.get(id);
  if (p) world.mind.setLevel(p, 'hunger', world.mind.level(p, 'hunger') - amount);
});

world.input('feed', ['kofi', 0.5]);

Saving

localStorage.setItem('save', JSON.stringify({ game: game.s, tyrell: world.save() }));

// on start: declare everything, then load
const saved = JSON.parse(localStorage.getItem('save'));
if (saved) world.load(saved.tyrell);
world.focus([currentLevel]);

world.save() is the whole state as plain JSON. On load, declare everything first (or after: both work), and world.person returns the saved person. Small pieces of game state that should rewind with the world go in world.keep. See Saving.

Layout: a side-scroller with many small levels

Games like Under the White Dragon show one small level at a time: a street, and each building’s interior as a level of its own, joined by doors and by the ends of streets.

Layout: a top-down game with one map

Games like Merryfolk show the whole village on one map, with everyone visible all the time and travel along tiles.

Checklist