Skip to content

Let the game decide a conversation ​

Goal ​

A talking character (Convorcher) whose conversation has an outcome the game owns: a gatekeeper who questions the player, after which the game, not the model's words, decides to let them through or refuse, gives a quick retry on refusal, and never counts a slow or failed reply against the player. Builds on Talk to a character.

Files you will edit ​

  • src/guard.story.yaml
  • src/systems/talk.ts

Steps ​

  1. Objectives as signals. In the story file, one objective per thing the game wants to know, each a player_state condition the service judges after every turn. They are evidence for the game, not decisions: the character is told to question and react, never to rule.

    yaml
    phases:
      questioning:
        description: 'The player wants to pass. Question them; notice whether their story holds together.'
        tone: 'calm, watchful'
        max_turns: 0
        objectives:
          - id: told-true-reason
            description: 'The player gave the true reason: medicine for a sick relative.'
            completion:
              {
                type: player_state,
                condition: 'The player has said they must fetch medicine for a sick relative.',
              }
            urgency: normal
          - id: stayed-consistent
            description: 'Asked again, the player kept to the same reason.'
            completion:
              {
                type: player_state,
                condition: 'The player has repeated or expanded the same reason without changing it, after being questioned.',
              }
            urgency: normal
          - id: contradicted
            description: 'The player changed their story, lied or threatened.'
            completion:
              {
                type: player_state,
                condition: 'The player has contradicted an earlier answer, admitted a lie, or threatened the character.',
              }
            urgency: normal

    The personality ends with: "Never say whether they may pass; the decision is not yours."

  2. The verdict, in the game. In src/systems/talk.ts, a small state machine: count the player's real turns (a reply came back), decide on story snapshots, and give up after a few turns.

    ts
    export const verdict = {
      state: 'talking' as 'talking' | 'allowed' | 'refused',
      turns: 0,
      asked: false,
    };
    const MAX_TURNS = 6;
    
    function decide(story: ConversationStory): void {
      if (verdict.state !== 'talking') return;
      const done = story.completedObjectives;
      if (done.includes('contradicted')) verdict.state = 'refused';
      else if (done.includes('told-true-reason') && done.includes('stayed-consistent'))
        verdict.state = 'allowed';
      else if (verdict.turns >= MAX_TURNS) verdict.state = 'refused';
    }

    In the event loop of talkSystem:

    ts
    if (kind === 'input' && verdict.state === 'talking') {
      verdict.asked = true;
      conversation.command(talkState.npc, 'ask', TALK_ID, text);
    } else if (kind === 'status' && text === 'ready' && verdict.asked) {
      verdict.asked = false;
      verdict.turns += 1; // a reply came back: that was one of the player's turns
    } else if (kind === 'error' && text.startsWith('retry:')) {
      verdict.asked = false; // the service's fault: not a turn, and the HUD says "ask again"
    } else if (kind === 'story') {
      const story = conversation.story(text);
      if (story && story.revision > talkState.lastRevision) {
        talkState.lastRevision = story.revision;
        decide(story);
      }
    }

    Import ConversationStory from gameable with conversation.

  3. Act on it. When verdict.state turns allowed, end the talk and open the way (an end card, a door, the next level). On refused, show it, then a quick retry: end the talk, reset verdict and talkState.lastRevision, and start again from the same spot. start with the text fresh begins the story again from its first phase (without it, the character picks up where the last session was).

    ts
    export function retry(): void {
      const npc = talkState.npc;
      conversation.command(npc, 'end', TALK_ID);
      verdict.state = 'talking';
      verdict.turns = 0;
      verdict.asked = false;
      talkState.lastRevision = -1;
      talkState.open = true;
      conversation.command(npc, 'start', TALK_ID, 'fresh');
    }

Verify ​

  • Give a vague answer, then the true one, then repeat it when questioned: allowed after the snapshot that completes both objectives (snapshots can come a turn after the reply).
  • Change your story halfway: refused, then the retry starts a fresh conversation.
  • Cut the network mid-reply: an error with retry: and no turn counted.

Notes ​

  • Snapshots lag a turn behind the reply: decide on a new revision, never on ready.
  • The model's words never decide anything. If the character announces a decision anyway, tighten the personality; the game's state is still what happens.

See also ​