Joi

An optional language-model voice. Joi turns a person’s state (temperament, what they’re doing, their needs, mood, feelings and memories, what they think of the listener, the line Cityspeak would have picked) into a prompt, asks the game’s provider for a line, and hands back text. Named for Blade Runner 2049’s companion, who is all voice.

Joi never changes the world. If the provider is slow, missing or fails, the written line comes back instead. A provider may suggest flags from a list the game allows; Joi returns them for the game to act on and doesn’t apply them. Because nothing changes, time walk is never affected.

Setting it up

Joi does nothing until the game gives it a provider: an async function that takes a request and returns a reply. Keep API keys off the client: point the provider at your own server, which calls the model.

world.joi.voice = 'A rainy harbor town. People are wry, warm and a little nosy. One or two short sentences.';
world.joi.allow(['offers_job', 'refuses_service']);
world.joi.setProvider(async (req) => {
  const res = await fetch('/api/voice', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ system: req.system, prompt: req.prompt }),
  });
  if (!res.ok) throw new Error('voice failed');
  return res.json();   // { text, flags? } or a plain string
});

JoiRequest:

Field Meaning
prompt The full prompt Joi built
system joi.voice: a short line for the game’s voice
who The speaker
to The listener, if any
line The Said Cityspeak would choose (the fallback), or null

A JoiReply is a string, or { text, flags? }.

Speaking

const spoken = await world.joi.speak('ruth', { topic: 'greet', to: 'player', timeoutMs: 2000 });
showBubble('ruth', spoken.text || strings[spoken.key]);

speak(who, { to?, topic?, timeoutMs? }) returns a promise of a Spoken:

Field Meaning
text The words: the model’s, or the written line’s text ('' if the line has none)
key The key of the line Cityspeak chose, or null
flags Allowed flags the model suggested
fromModel Whether the text came from the model

It falls back to the written line when there’s no provider, when the provider takes longer than timeoutMs (config.timeoutMs, 2500, by default), when it throws, or when it returns empty text.

Because the reply comes back later, a game shouldn’t wait on it in the frame loop. Show the written line at once if the conversation can’t wait, or show a “…” until the promise settles. Speaking with Joi doesn’t say the line in Cityspeak’s sense: if the line’s once, cooldown or then should count, call cityspeak.say too.

The prompt

joi.prompt(who, { to?, topic? }) returns the prompt without sending it, which is useful for tuning. It’s built from:

Give actions labels, memories text, and feelings labels so the prompt reads well.

Flags

A provider can suggest that something should happen: the speaker offers a job, refuses to serve the player. Only flags passed to joi.allow come back; the rest are dropped. Joi doesn’t act on them. The game decides what a flag means, and makes the change through the API, like any other input:

const spoken = await world.joi.speak('mara', { topic: 'work', to: 'player' });
if (spoken.flags.includes('offers_job')) world.fact('job_offer:mara', true);

API

world.joi:

Member    
voice string The system line sent with every request
setProvider(fn) this Sets the provider (null removes it)
allow(flags) this Flags a reply may suggest
prompt(who, { to?, topic? }) string The prompt for a person speaking
speak(who, { to?, topic?, timeoutMs? }) Promise of Spoken Asks the provider, or falls back

Joi has no state, terms, effects or events. Its config (moduleConfig: { joi: { ... } }) shapes only the prompt and the wait: wants (0.6), memories (3) and timeoutMs (2500). It has no headline knob, because it never changes the world. See Tuning.

With other modules

Joi reads the world through Cityspeak’s choose, which only looks: a line whose conditions use chance: changes nothing (see Conditions).

Example

world.joi.voice = 'A small seaside town. Warm, a little nosy.';
world.joi.allow(['offers_tea']);
world.joi.setProvider(async (req) => ({ text: 'Morning! Kettle is on.', flags: ['offers_tea', 'burns_house'] }));

world.joi.speak('ruth', { topic: 'greet', to: 'player' }).then((s) => {
  console.log(s.text, s.flags, s.fromModel);   // 'Morning! Kettle is on.' ['offers_tea'] true
});