Gaff
The director: storylets that fire on a mean time between events, gated by conditions, cooldowns and the phase of an intensity cycle, drawn from shuffled decks so nothing repeats back to back, and placed where nobody is when that matters. Named for the Blade Runner detective who watches everything and leaves small origami figures behind.
The ideas come from RimWorld’s storytellers (mean time between events), Left 4 Dead’s AI director (build-up, peak, fade and relax), and card decks for variety without repetition.
- Storylets
- When storylets fire
- Intensity and phases
- Decks
- Unseen places
- Metrics
- Storylets and time walk
- State
- API
- Terms, events and log
- Config
- With other modules
- Example
Storylets
world.gaff.storylet({
id: 'rain', label: 'Rain', mtb: 2 * 1440, cooldown: 1440, when: ['!fact:raining'],
fire: (w) => { w.fact('raining', true); w.at(w.now + 6 * 60, 'rain-stops'); },
});
world.on('timer:rain-stops', () => world.fact('raining', false));
StoryletDef:
| Field | Default | Meaning |
|---|---|---|
id |
required | The storylet’s id |
label |
the id | A readable name, sent with the director event |
when |
none | Conditions (strings, read with no person: world terms, facts, metrics, phases), or a function (world) => boolean |
mtb |
required | Mean minutes between firings while it’s eligible. Infinity means it never fires on its own (cards in a deck) |
cooldown |
none | Minutes before it can fire again |
phase |
any | Fires only in these phases of its subject’s intensity: 'build', 'peak', 'fade', 'relax', or a list |
subject |
'world' |
The intensity subject it reads and bumps ('player', 'docks'…) |
bump |
config.bump (0.2) |
How much firing raises the subject’s intensity (times the headline knob intensity) |
deck |
none | When it comes up, a card is drawn from this deck and fired instead |
unseen |
none | A tag: fired without a place, it fires at a place with this tag that nobody is at |
fire |
required | What happens: (world, { place, subject, id }) => void |
When storylets fire
Gaff checks its storylets every game hour, on the hour, in the order they were declared. Each storylet that’s eligible (not cooling down, in one of its phases, its when holds) fires with probability 1 - exp(-60 × events / mtb), so on average once every mtb minutes of eligible time; events is the headline knob (1; 2 is twice as often, 0 never on their own). Firing:
- records the time (for the cooldown) and the firing (the last 100,
history()); - bumps its subject’s intensity by
bump; - logs
directorand emitsdirector({ id, place, label }); - runs
fire(world, { place, subject, id }).
The game can force one with world.gaff.trigger(id, place?), an input. A forced storylet fires at once, without checking eligibility or drawing from a deck.
Intensity and phases
Each subject has an intensity (0..1) and a phase, moving through a cycle every hour:
| Phase | Moves on when |
|---|---|
build |
intensity reaches peakAt (0.7): to peak |
peak |
peakMinutes (4 hours) have passed: to fade |
fade |
intensity drops under 0.4 × peakAt (0.28; relaxAt is the 0.4): to relax |
relax |
relaxMinutes (12 hours) have passed: to build |
Intensity falls by decay (0.04) every hour, and rises when storylets fire (bump) or when the game bumps it (gaff.bump(subject, by), an input). Give the big events phase: ['build'] so they come while tension builds, and quiet ones phase: ['relax'].
A subject comes into being the first time it’s bumped. Until then it reads as building from 0 (gaff.phase('docks') is 'build'), and reading it changes nothing.
Decks
A deck is a list of storylet ids drawn in a shuffled order, with no card twice in a row (even across a reshuffle).
world.gaff.storylet({ id: 'festival_season', mtb: 2 * 1440, phase: ['build', 'relax'], deck: 'festivals', fire: () => {} });
world.gaff.deck('festivals', ['street_party', 'lantern_night', 'market_day']);
world.gaff.storylet({ id: 'street_party', mtb: Infinity, cooldown: 3 * 1440, fire: (w) => w.fact('festival', true) });
When a storylet with deck comes up, a card is drawn and the card’s storylet fires instead (if the card is eligible; if not, nothing fires, and the card is used up). The trigger’s own fire isn’t called. Give cards mtb: Infinity so they only come from the deck.
Cooldowns work on both. A cooldown on the trigger spaces out the deck as a whole (it counts from the last time the trigger fired a card); a cooldown on a card keeps that card from coming back too soon.
Unseen places
Some events should happen where nobody sees them happen: a break-in, a fire, a body found later. With unseen: 'tag', a storylet fired without a place picks one with that tag that nobody is at (null if there’s none), from the world’s stream, and passes it to fire as place.
That choice doesn’t look at what the game is showing, on purpose. Focus isn’t part of the world’s state and isn’t recorded, so a choice that read it could come out differently when Esper replays the firing (the game may be showing anything then), and a watched world would differ from an unwatched one. So Gaff chooses from the state alone, and leaves the view to the game. To keep an event out of the player’s sight:
- Fire it yourself.
gaff.unseenPlace(tag)picks a place with the tag that nobody is at and that isn’t in a focused region, without changing anything. Pass it togaff.trigger(id, place): the place is part of the recorded input, so a replay fires at the same place whatever is on screen then. Give the storyletmtb: Infinityso it only fires this way. - Or gate it with a fact. If the game keeps a fact such as
player-in:<region>up to date (an input, so it rewinds), awhen: ['!player-in:docks']keeps automatic firings away from where the player is.
world.gaff.storylet({ id: 'fire', mtb: Infinity, unseen: 'shop', fire: (w, { place }) => { if (place) w.fact('burnt:' + place, true); } });
// from the game's own code, when it decides it's time:
const place = world.gaff.unseenPlace('shop');
if (place) world.gaff.trigger('fire', place);
Metrics
Metrics are numbers when conditions can read as metric:<name>:
| Metric | Value |
|---|---|
mood |
The mean mood of everyone active (0.5 without Reverie) |
people |
How many people are active |
heat.<actor> |
Wormtongue’s heat on someone |
| your own | gaff.defineMetric(name, (world) => number) |
world.gaff.defineMetric('closed', (w) => [...w.ring.defs.keys()].filter((id) => !w.ring.open(id)).length);
world.gaff.storylet({ id: 'crackdown', mtb: 1440, when: ['metric:heat.player>8'], fire: (w) => w.direct('vance', { do: 'patrol', at: 'market', until: w.now + 240 }) });
Storylets and time walk
A storylet’s fire is game code the engine runs. It runs inside world.internally(), so the calls it makes (w.fact, w.direct, w.at…) are applied but not recorded: a replay runs fire again and makes them again. So fire must be deterministic: draw randomness from w.worldStream(name) and read only Tyrell’s state (facts, people, kept values). The test suite checks that a world with storylets rewinds and replays exactly.
State
In world.s.mod.gaff:
| Key | Holds |
|---|---|
last |
When each storylet last fired |
decks |
Per deck: the cards left and the last card drawn |
intensity |
Per subject: { v, phase, since } |
fired |
The last 100 firings: { t, id, place } |
API
world.gaff:
| Method | Returns | |
|---|---|---|
storylet(def) |
this | Declares a storylet |
deck(id, cards) |
this | Declares a deck |
defineMetric(name, fn) |
this | Declares a metric |
metric(name) |
number | A metric’s value |
intensity(subject = 'world') |
number | A subject’s intensity |
phase(subject = 'world') |
Phase | A subject’s phase |
bump(subject, by) |
void | Input. Raises (or lowers) a subject’s intensity |
trigger(id, place = null) |
void | Input. Fires a storylet now |
eligible(def) |
boolean | Could it fire now? Changes nothing (a chance: in its when is a look) |
history() |
array | The last 100 firings |
draw(deck) |
id or undefined | Input. Draws a card (applies at once): for decks the game deals from itself |
applyDraw(deck) |
id or undefined | Engine. The direct version |
unseenPlace(tag) |
id or null | A place with the tag that nobody is at and that isn’t in a focused region. Changes nothing; pass it to trigger |
applyBump(subject, by) |
void | Engine. The direct version of bump |
config, storylets |
The config and the declared storylets |
Terms, events and log
Terms: metric:<name>, intensity / intensity:<subject>, phase:<phase> / phase:<subject>.<phase>.
| Event | Payload |
|---|---|
director |
{ id, place, label }, emitted just before fire runs |
Log entry: director ({ storylet, place }).
Config
Tyrell.create({ moduleConfig: { gaff: { ... } } }):
| Field | Default | Meaning |
|---|---|---|
peakAt |
0.7 | Intensity at which the build-up turns into a peak |
peakMinutes |
240 | Minutes a peak lasts |
relaxMinutes |
720 | Minutes of relaxing after the fade |
decay |
0.04 | Intensity lost per hour |
events |
1 | Headline knob: how often storylets fire on their own (0: never; trigger still fires them) |
intensity |
1 | Headline knob: how much a firing raises its subject’s intensity |
bump |
0.2 | A storylet’s bump when it gives none |
relaxAt |
0.4 | A fade turns to relaxing under this share of peakAt |
Gaff’s knobs are the world’s only: storylets aren’t anyone’s. See Tuning; during play, world.tune('gaff', { ... }).
With other modules
- Reverie: the
moodmetric is the town’s mean mood, so storylets can ease off when people are miserable. - Wormtongue:
metric:heat.<actor>lets the director react to the player’s heat. - The clock:
unseenPlacereads the focused regions; storylets’ timers (w.at) come back astimer:events the game listens to. - Everything else through
fire, which can use the whole API: facts, directives, feelings, payments, births.
Example
const gaff = world.gaff;
gaff.storylet({
id: 'blackout', label: 'A blackout at the Belt', mtb: 6 * 1440, cooldown: 3 * 1440, phase: ['build', 'relax'],
fire: (w) => { w.fact('closed:bar', true); w.at(w.now + 3 * 60, 'power-back'); },
});
world.on('timer:power-back', () => world.fact('closed:bar', false));
world.on('director', (e) => showHeadline(e.label));
gaff.storylet({
id: 'break_in', label: 'A break-in', mtb: 4 * 1440, unseen: 'home',
fire: (w, { place }) => { if (place) w.fact('broken_into:' + place, true); },
});