Branching conversations with choices, variables and effects that your game carries out.
Import blitz.dialogue As Talk gives your program these, with Talk. in front. Each name leads to its part of the reference.
LastError:Str()ParseDialogue:Dialogue(json:Int)LoadDialogue:Dialogue(file:Str)StartDialogue:Conversation(definition:Dialogue, variables:Int, rules:Rules)DialogueView:View(session:Conversation)RefreshDialogue(session:Conversation)ChooseReply:Int(session:Conversation, choiceId:Str)AdvanceDialogue:Int(session:Conversation)SetVariable:Int(session:Conversation, name:Str, value:Int)Variable:Int(session:Conversation, name:Str)NextEvent:DialogueEvent(session:Conversation)CaptureDialogue:Int(session:Conversation)RestoreDialogue:Conversation(definition:Dialogue, snapshot:Int, rules:Rules)DialogueWarningCount:Int(), Warning:Str(index:Int)ReplyId:Str(), Label:Str(), Enabled:Int()ViewNodeId:Str(), Speaker:Str(), LineText:Str(), Ended:Int(), ChoiceCount:Int(), ChoiceAt:Reply(index:Int)DialogueEventName:Str(), NodeId:Str(), Sequence:Long(), Payload:Int(), Variables:Int()ConversationRulesAllows:Int(name:Str, arguments:Int)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.

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.
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}
}
}
nodes lists the lines by name, and start names the first.speaker and text, and ends in exactly one way: with
"end":true, with next, the line that follows when the player carries
on, or with choices.id for your program, the text the player sees and the
target line it leads to. It can also have a condition and effects.effects happen when the player reaches a line or makes a choice: set
and add change variables, and emit sends your game a request.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.
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).
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.
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
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.
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
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.
ChooseReply, AdvanceDialogue,
SetVariable and RefreshDialogue each make a new view, so ask for it
again afterwards, as the program does, and never delete it.add to a variable that isn't a number, nothing changes.Three more complete programs come with the module:
The reference below has every function and rule.
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.
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.
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:
| Operation | Required fields | Meaning |
|---|---|---|
eq, ne | var, scalar value | Scalar equality/inequality; numbers compare by value (1 equals 1.0) |
lt, le, gt, ge | var, numeric value | Numeric comparison |
all, any | items array of conditions | Left-to-right, short-circuit; empty all=true, any=false |
not | item condition | Negation |
rule | name, JSON args | Calls 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:
| Operation | Required fields | Meaning |
|---|---|---|
set | var, scalar value | Create or replace a variable |
add | var, numeric value | Add to an existing numeric variable; fail on wrong type/overflow |
emit | name, JSON payload | Queue 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.
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.
| Operation | Result and lifetime | Example |
|---|---|---|
LoadDialogue(file:Str) | Owned definition or Null; textual JSON, no open handle retained | Talk.LoadDialogue("shop.json") |
ParseDialogue(json:Int) | Owned definition or Null; input borrowed, retained data copied | Talk.ParseDialogue(json) |
StartDialogue(definition, variables:Int, rules:Rules) | Owned conversation or Null; copies variables, borrows other inputs | Talk.StartDialogue(book, 0, Null) |
DialogueView(session) | Borrowed read-only View; no evaluation/allocation | view = Talk.DialogueView(session) |
ChooseReply(session, choiceId:Str):Int | True on atomic transition | Talk.ChooseReply(session, "buy") |
AdvanceDialogue(session):Int | True only at a next node | Talk.AdvanceDialogue(session) |
RefreshDialogue(session) | Replace view by reevaluating conditions; no effects | Talk.RefreshDialogue(session) |
SetVariable(session, name:Str, value:Int):Int | Copies scalar, refreshes view atomically; no entry effects | Talk.SetVariable(session, "purchased", flag) |
Variable(session, name:Str):Int | Owned scalar JSON, or 0 for absent | value = Talk.Variable(session, "gold") |
NextEvent(session) | Transfer oldest DialogueEvent, or Null | event = Talk.NextEvent(session) |
CaptureDialogue(session):Int | Owned JSON snapshot, no file write or mutation | saved = Talk.CaptureDialogue(session) |
RestoreDialogue(definition, snapshot:Int, rules:Rules) | New owned session or Null; inputs borrowed/copied | Talk.RestoreDialogue(book, saved, Null) |
LastError():Str | Main-thread diagnostic, not a global runtime error | message = 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.
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.
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.