Dialogue

Branching conversations with choices, variables and effects that your game carries out.

Commands

Import blitz.dialogue As Talk gives your program these, with Talk. in front. Each name leads to its part of the reference.

Functions

Types

Interfaces

Guide

Dialogue runs the conversations in your game: who says what, the replies the player can choose, and where each reply leads. You write a conversation as a set of lines, each with a name, and the module keeps track of where the player is, which replies they can choose and what the conversation remembers. Your game draws the words, and decides what really happens when the conversation asks for something, such as selling the player some soup.

An innkeeper's greeting with four replies: soup, which the player can't afford, so it's greyed out, news, the dragon, and goodbye

Try it

Open inn.bb and run it. Press the number keys to reply, and Space when a line has no replies. Ask for news, and a new question appears. Buy soup, and see what happens when you can't afford another bowl.

; A chat with an innkeeper. Press 1 to 4 to choose what to say, Space to
; carry on, and Escape to quit.
Dialect "modern"
Import blitz.dialogue As Talk

Graphics 640, 200, 0, 2
SetBuffer BackBuffer()
ClsColor 18, 52, 79

; The conversation, written in JSON. Single quotes keep it easy to read here,
; and Replace turns them into the double quotes that JSON needs.
Local script:Str = "{'version':1, 'id':'inn', 'revision':'1', 'start':'hello', 'nodes':{"
script = script + "'hello':{'speaker':'Mira', 'text':'Welcome to the Sleepy Fox!', 'choices':["
script = script + "  {'id':'soup', 'text':'Some soup, please (2 gold)', 'target':'soup',"
script = script + "   'condition':{'op':'ge', 'var':'gold', 'value':2}, 'hide':false,"
script = script + "   'effects':[{'op':'emit', 'name':'buy-soup', 'payload':{}}]},"
script = script + "  {'id':'news', 'text':'Any news?', 'target':'news'},"
script = script + "  {'id':'dragon', 'text':'Tell me about the dragon', 'target':'dragon',"
script = script + "   'condition':{'op':'eq', 'var':'heard', 'value':true}},"
script = script + "  {'id':'bye', 'text':'Goodbye', 'target':'bye'}]},"
script = script + "'soup':{'speaker':'Mira', 'text':'Here you are. Mind, it is hot!',"
script = script + "  'next':'hello'},"
script = script + "'news':{'speaker':'Mira', 'text':'They say a dragon sleeps under the bridge.',"
script = script + "  'next':'hello', 'effects':[{'op':'set', 'var':'heard', 'value':true}]},"
script = script + "'dragon':{'speaker':'Mira', 'text':'Nobody who went to look came back.',"
script = script + "  'next':'hello'},"
script = script + "'bye':{'speaker':'Mira', 'text':'Safe travels!', 'end':true}}}"
Local json:Int = ParseJson(Replace(script, "'", Chr(34)))
Local definition:Talk.Dialogue = Talk.ParseDialogue(json)
FreeJson json
If definition = Null Then RuntimeError Talk.LastError()

; The game keeps the gold, and tells the conversation how much there is.
Local gold:Int = 3
Local session:Talk.Conversation = Talk.StartDialogue(definition, 0, Null)
Local amount:Int = CreateJsonInteger(gold)
Talk.SetVariable session, "gold", amount
FreeJson amount
Local index:Int

While Not KeyDown(1)
    ; A number key picks a reply by its place in the list. Space carries on.
    Local view:Talk.View = Talk.DialogueView(session)
    For index = 0 To view.ChoiceCount() - 1
        If KeyHit(index + 2) ; The 1 key is KeyHit(2).
            Local reply:Talk.Reply = view.ChoiceAt(index)
            Talk.ChooseReply session, reply.Id()
            Exit ; Choosing replaces the view, so stop using this one.
        EndIf
    Next
    If KeyHit(57) Then Talk.AdvanceDialogue session

    ; The conversation asks the game to do things. The game decides how.
    Local request:Talk.DialogueEvent = Talk.NextEvent(session)
    While request <> Null
        If request.Name() = "buy-soup"
            gold = gold - 2
            amount = CreateJsonInteger(gold)
            Talk.SetVariable session, "gold", amount
            FreeJson amount
        EndIf
        Delete request
        request = Talk.NextEvent(session)
    Wend

    Cls
    view = Talk.DialogueView(session)
    Color 255, 220, 0
    Text 20, 20, view.Speaker() + ": " + view.LineText()
    For index = 0 To view.ChoiceCount() - 1
        reply = view.ChoiceAt(index)
        Color 255, 255, 255
        If Not reply.Enabled() Then Color 120, 140, 160 ; Shown, but can't be chosen.
        Text 40, 60 + index * 25, (index + 1) + ". " + reply.Label()
    Next
    Color 255, 255, 255
    If view.ChoiceCount() = 0 And (Not view.Ended()) Then Text 40, 60, "Space: carry on"
    Text 20, 170, "Gold: " + gold
    Flip
Wend
Delete session
Delete definition
End

The conversation remembers things in variables. Asking for news sets heard, and the dragon question only shows once heard is true. The soup is shown even when you can't afford it, because of 'hide':false, but greyed out. When you buy soup, the conversation doesn't take any gold: it sends the game a request, buy-soup, and the game takes the gold and tells the conversation how much is left. The single quotes are why Mira says "it is" rather than "it's": an apostrophe would turn into a double quote too, and break the JSON.

Describe your conversations

A game usually keeps each conversation in a file of its own:

{
  "version": 1,
  "id": "inn",
  "revision": "1",
  "start": "hello",
  "nodes": {
    "hello": {"speaker":"Mira", "text":"Welcome!", "choices":[
      {"id":"news", "text":"Any news?", "target":"news"},
      {"id":"bye", "text":"Goodbye", "target":"bye"}
    ]},
    "news": {"speaker":"Mira", "text":"A dragon sleeps under the bridge.", "next":"hello",
      "effects":[{"op":"set", "var":"heard", "value":true}]},
    "bye": {"speaker":"Mira", "text":"Safe travels!", "end":true}
  }
}

version is always 1. id names the conversation and revision is a label of your own, such as "1". These and the other names in the file, such as line names, use only letters, digits, -, _ and .. A save loads into any revision of the same conversation, as long as the lines it needs are still there. The module checks the file carefully: a misspelt field or a target that isn't there stops it loading, and LastError says what's wrong.

How do I...

Load a conversation from a file?

Local definition:Talk.Dialogue = Talk.LoadDialogue("inn.json")
If definition = Null Then RuntimeError Talk.LastError()
Local session:Talk.Conversation = Talk.StartDialogue(definition, 0, Null)

The 0 means the conversation starts with no variables, and Null that it has no rules (see below).

Show a reply only when it makes sense?

Give it a condition. A condition compares a variable with a value, using eq, ne, lt, le, gt or ge, or puts other conditions together with all, any and not:

{"id":"rest", "text":"A room for the night (5 gold)", "target":"room",
 "condition":{"op":"all", "items":[
   {"op":"ge", "var":"gold", "value":5},
   {"op":"not", "item":{"op":"eq", "var":"sleeping", "value":true}}]}}

A reply whose condition is false is hidden. Add "hide":false to show it greyed out instead, as the soup is.

Hand the game something to do?

Add an emit effect with a name and a payload, which can hold anything the game needs to know, such as {"item":"map"}. The game reads its requests with NextEvent, and the payload is a copy that's yours to free.

Local request:Talk.DialogueEvent = Talk.NextEvent(session)
While request <> Null
    If request.Name() = "give"
        Local payload:Int = request.Payload()
        Local item:Int = JsonObjectGet(payload, "item")
        GiveItem JsonText(item)
        FreeJson item
        FreeJson payload
    EndIf
    Delete request
    request = Talk.NextEvent(session)
Wend

Check something only the game knows?

Write a Type that Implements Talk.Rules, and use a rule condition, such as {"op":"rule", "name":"evening", "args":{}}. The conversation asks your Allows method each time it works out which replies to show, and again when one is chosen.

Type Clock Implements Talk.Rules
    Field hour:Int
    Method Allows:Int(name:Str, arguments:Int)
        If name = "evening" Then Return Self.hour >= 18
        Return False
    End Method
End Type

Pass one to StartDialogue in place of Null, and delete it after the conversation. When the hour changes, call Talk.RefreshDialogue session so the replies shown catch up.

Find out what the conversation remembers?

Variable gives you a copy of a variable, which you free, or 0 if there's no such variable yet.

Local heard:Int = Talk.Variable(session, "heard")
If heard <> 0
    If JsonBoolean(heard) Then Print "The player has heard about the dragon"
    FreeJson heard
EndIf

Save in the middle of a conversation?

CaptureDialogue gives you the conversation as JSON, to save with the rest of your game. RestoreDialogue makes a new conversation from it, at the same line, without running that line's effects again.

Local saved:Int = Talk.CaptureDialogue(session)
; Later:
Local restored:Talk.Conversation = Talk.RestoreDialogue(definition, saved, Null)
If restored <> Null
    Delete session
    session = restored
EndIf
FreeJson saved

Blip's tip

A comparison with a variable that hasn't been set is always false, even ne. If a reply never appears, check that your game has told the conversation the value it needs, as the program above does with the gold before the player can choose anything.

Good to know

More examples

Three more complete programs come with the module:

The reference below has every function and rule.

Reference

Needs
No other modules
Examples
quick_start.bb, save_and_typewriter.bb, shopkeeper.bb

0.1.0 unpublished preview. Dialogue controls choices, variables and queued requests. Your game owns rendering, time, rewards and purchase acceptance. Requires Extended object-lifecycle-v1 (runtime 1114 / module ABI 5 or newer). No package dependencies. Importing creates no session, files or window.

Quick start

Blitz3D+ comes with this module, so any Extended program can Import blitz.dialogue. The example uses single quotes only as a readable Blitz string convention; Replace converts them into proper JSON before parsing.

Dialect "modern"
Import blitz.dialogue As Talk
Local json:Int = ParseJson(Replace("{'version':1,'id':'hello','revision':'1','start':'a','nodes':{'a':{'text':'Hello!','next':'b'},'b':{'text':'Goodbye!','end':true}}}", "'", Chr(34)))
Local definition:Talk.Dialogue = Talk.ParseDialogue(json)
FreeJson json
If definition = Null Then RuntimeError Talk.LastError()
Local session:Talk.Conversation = Talk.StartDialogue(definition, 0, Null)
If session = Null Then RuntimeError Talk.LastError()
Local presentation:Talk.View = Talk.DialogueView(session)
Print presentation.LineText()
If Not Talk.AdvanceDialogue(session) Then RuntimeError Talk.LastError()
presentation = Talk.DialogueView(session)
Print presentation.LineText()
Delete session
Delete definition
End

Start/restore take explicit Null Rules. Object/interface optional Null defaults are unsupported by the current compiler; initial variables are also explicit (0 means empty). Read a returned view into a local before calling its methods: postfix chaining after module/method calls is unsupported. LineText avoids ambiguity with the drawing command. Choice records use Id, Label, Enabled.

Definition format, version 1

Root: version:1, id, revision, start, nodes (object keyed by stable IDs). Each node has optional speaker, text, effects, and exactly one exit: end:true, next:"id", or nonempty choices. A next node needs a player AdvanceDialogue unless auto:true; automatic nodes are never presented. Automatic-only cycles and chains over 64 steps are rejected, even unreachable ones. Cycles containing an explicit advance or choice are allowed. Unreachable nodes produce warnings, in the JSON runtime's deterministic key order.

Choices contain id, text, target, optional condition, optional hide (default true), and optional effects. IDs must be unique within a node; they are interpreted only at the current node. If a later node reuses an ID, that ID means its current choice. Games retaining a UI action across transitions should compare its node ID too. A false condition hides the choice, or shows a disabled choice when hide:false. Selection always rechecks the condition; the display is a snapshot, never proof of eligibility. RefreshDialogue refreshes presentation after external rule state changes.

Conditions are objects:

OperationRequired fieldsMeaning
eq, nevar, scalar valueScalar equality/inequality; numbers compare by value (1 equals 1.0)
lt, le, gt, gevar, numeric valueNumeric comparison
all, anyitems array of conditionsLeft-to-right, short-circuit; empty all=true, any=false
notitem conditionNegation
rulename, JSON argsCalls borrowed Rules.Allows; absent Rules means false

All have op. Missing variables make a comparison false, including ne; explicit JSON null is a value. A string never equals a number, and other kinds must match exactly. Ordered comparisons with a nonnumeric variable are false. Scalar numbers are finite and within ±9,007,199,254,740,991. Integer adds remain exact within that range; mixed/real arithmetic uses binary64. No expression strings, arbitrary dispatch, scripts or Eval are supported.

Effects execute in listed order:

OperationRequired fieldsMeaning
setvar, scalar valueCreate or replace a variable
addvar, numeric valueAdd to an existing numeric variable; fail on wrong type/overflow
emitname, JSON payloadQueue a request with copied payload and variables at this position

On selection, choice effects precede destination entry effects and any automatic chain. Start applies entry effects once. A transition stages copied variables, events and the new view. A bad add, variable limit, exhausted sequence or full queue rejects the entire operation: old node, variables, view, counters and queue remain unchanged. This covers expected validation failures; allocation failure or a caller's fatal RuntimeError is not a recoverable transaction. External game effects occur only after polling committed events.

Limits: 1 MiB stringified definition, 1–1024 nodes, 1–64 choices per choice node, 0–64 effects per node/choice, condition depth 16 and fan-out 64, 64 automatic steps, 256 variables and 65,536 stringified bytes per variable object, 256 queued events, 16,384 stringified bytes per payload/argument. IDs, revision, variable and event/rule names are 1–64 ASCII letters/digits/_.-; strings are at most 4096 characters. Unknown fields, wrong types, unknown operations and missing targets are rejected. Runtime ParseJson/LoadJson reject duplicate object keys before the module receives JSON. Limits bound input, traversal and backlog; this is intended for main-thread game conversations, not unbounded documents.

API and ownership

All functions are module-qualified. Expected rejections return False/Null; LastError() is cleared by every fallible operation and reports malformed data, failed effects and queue capacity. Unavailable choices, terminal advance and an empty event queue have no error. Copy diagnostics immediately. Null/deleted resources, out-of-range read indexes and callback/lifetime violations are fatal programming errors. Invalid native JSON handles remain runtime errors.

OperationResult and lifetimeExample
LoadDialogue(file:Str)Owned definition or Null; textual JSON, no open handle retainedTalk.LoadDialogue("shop.json")
ParseDialogue(json:Int)Owned definition or Null; input borrowed, retained data copiedTalk.ParseDialogue(json)
StartDialogue(definition, variables:Int, rules:Rules)Owned conversation or Null; copies variables, borrows other inputsTalk.StartDialogue(book, 0, Null)
DialogueView(session)Borrowed read-only View; no evaluation/allocationview = Talk.DialogueView(session)
ChooseReply(session, choiceId:Str):IntTrue on atomic transitionTalk.ChooseReply(session, "buy")
AdvanceDialogue(session):IntTrue only at a next nodeTalk.AdvanceDialogue(session)
RefreshDialogue(session)Replace view by reevaluating conditions; no effectsTalk.RefreshDialogue(session)
SetVariable(session, name:Str, value:Int):IntCopies scalar, refreshes view atomically; no entry effectsTalk.SetVariable(session, "purchased", flag)
Variable(session, name:Str):IntOwned scalar JSON, or 0 for absentvalue = Talk.Variable(session, "gold")
NextEvent(session)Transfer oldest DialogueEvent, or Nullevent = Talk.NextEvent(session)
CaptureDialogue(session):IntOwned JSON snapshot, no file write or mutationsaved = Talk.CaptureDialogue(session)
RestoreDialogue(definition, snapshot:Int, rules:Rules)New owned session or Null; inputs borrowed/copiedTalk.RestoreDialogue(book, saved, Null)
LastError():StrMain-thread diagnostic, not a global runtime errormessage = Talk.LastError()

Dialogue.WarningCount() and Warning(index) expose unreachable-node warnings. View.NodeId(), Speaker(), LineText(), Ended(), ChoiceCount() and ChoiceAt(index) expose presentation. Reply.Id(), Label(), Enabled() read a borrowed choice. Indexes are zero-based. DialogueEvent.Name(), NodeId() and Sequence() read metadata; Payload() and Variables() each return an owned JSON copy. The sequence is a monotonically increasing Long per session, starting at 1 and bounded by the same safe integer limit as snapshot counters. These read methods have no alternative global spellings.

Delete transferred events and conversations; FreeJson copied variables, payloads and captures. Delete all conversations before their immutable definition and borrowed Rules. Never delete borrowed views/replies. A successful transition, SetVariable or Refresh invalidates the previous view and replies; failed actions, capture, reading and polling events leave them valid. Deleting a conversation deletes its owned JSON, view/replies, queue and undelivered events, with no Rules calls or final game effects. Already deleted Type aliases compare Null and are safe to Delete. Assigning Null/leaving scope is not cleanup.

Callbacks, timing and saving

Rules implements Allows(name:Str, arguments:Int):Int. It borrows its arguments for that call only: do not mutate, retain or free them. The implementation passes a fresh copy to protect definitions from accidental edits. Rule calls happen while preparing a view or rechecking a selection. Calls must be pure and stable within an outer operation; invocation counts are not gameplay notifications. Reading the old live view or copying a variable is allowed. Session mutation, capture, parsing/loading, event polling, start/restore and conversation deletion from any Rules callback are rejected. Deleting a borrowed owner is a programming error; schedule deletion after the outer call returns. No deferred destruction.

The core has no clock, random generator, input polling or rendering. Supply game outcomes explicitly, update external rule state, refresh, read input once, apply dialogue actions, drain requests, then draw. The advanced example owns seconds, pause and typewriter progress separately; reveal does not mutate a conversation.

Capture stores version, document ID/revision, current node, entered:true, variables, last emitted sequence, delivery watermark and all pending event snapshots. Restore validates them before returning a session and never applies entry effects. It rebuilds the view using current Rules. It cannot restore into an automatic intermediate node. The saved revision is recorded but not required: a save restores into any revision of the same document that still has its current and queued-event nodes, so text fixes never break saves. A missing node is rejected. When a revision changes what saved variables mean, compare the snapshot's revision yourself and migrate before restoring.

Delivery means transfer from NextEvent, not successful purchase or reward. A capture after delivery does not replay that request; a capture before delivery preserves it. Save dialogue state together with game outcomes/watermarks in the same game save transaction. Restoring an old capture alone can intentionally redeliver old pending requests; this is not cross-crash exactly-once delivery. No automatic save/commit/reward occurs in Delete. Capture, file save and game commit remain distinct actions. There is no extra Stop/Cancel spelling because the first release has no timed action to stop; discard a session explicitly.

Complete examples

All accept finite --verify; Escape exits interactive use. No binary assets.

Deferred: quest/inventory/save/UI/localisation adapters until their prerequisites are accepted, voices/fonts/drawing in core, authoring tools, arbitrary scripting, worker execution and automatic cross-revision migration.