Narrative / visual novel
Both families (Ink and graph dialogue) plus the manifest-driven product shell: boot flow, versioned saves, chapter select.
The systems a story-driven game needs: branching script, on-screen dialogue, scripted
commands woven into prose, choices, chapter flow, and data-driven in-world content (a fake
phone/social-media OS). Reverse-engineered from a shipped commercial Godot narrative game
(real GDScript source), plus a studied graph-dialogue addon for the node-graph family and
the VN quality-of-life layer. Patterns are durable and language-agnostic in spirit; build
them via the CLI (script.create, scene.*, project.add_autoload). Read
gdscript-architecture.md first, because narrative leans on its Store/Service/Index spine.
The core idea: keep the story in a branching-script format, keep effects in code, and bridge them with two narrow seams. The seams are external functions (script ↔ game state) and a command bus (script → game actions). Everything else hangs off them.
Run branching script with Ink (or an equivalent runner)#
The game authors story in Ink (inkle’s narrative language), compiles to .inkb, and
runs it through a GDExtension runner. The pattern, independent of which runner you pick:
- Story files keyed hierarchically (
1.1a,2.3.flashback) resolving toid.inkb/id/_main.inkb. Localized files live underlocales/<lang>/…and the loader falls back to the default locale when a translation is missing. - A
Storyautoload owns runner creation, caching, and a tiny API: advance lines, read choices, read tags.
var story := InkStory.open(file)var runner := InkRunner.from_ink_story(story)runner.can_continue(); runner.advance(); runner.get_choices()runner.choose_choice_index(i); runner.get_global_tags()Pattern: story-as-data in a runner. Branching/state logic lives in the script language; GDScript only drives the runner and renders output. (If not Ink: any line-emitting branching format works, and the rest of this file is runner-agnostic.)
Seam 1: external functions (script ↔ game state)#
Ink calls back into GDScript through bound external functions, which the game wires to the Store. Reflect over methods with a prefix and auto-bind them:
class StoryExternalFunctions: const FUNC_PREFIX = "__" func bind(runner): for m in get_method_list(): if m.name.begins_with(FUNC_PREFIX): runner.bind_external_function(m.name.trim_prefix(FUNC_PREFIX), Callable(self, m.name)) func __get_bool(path, default): return Store.get_value(_state_path(path)) if ... else default func __set_int(path, v): Store.set_value(_state_path(path), v); return trueSo Ink can read/write persistent state (__set_bool, __is_fb_post_liked, …) without
knowing about Godot. Pattern: a thin, auto-bound bridge from the script VM to your state
store.
Substory-type routing from tags#
The runner’s global tags carry a header (type: "chat", characters, player,
color…). A factory reads the header and returns the right presentation object:
match header.type: "chat": return Conversation.new(id, runner, header, ctx) # messaging UI "dialogue": return Dialogue.new(id, runner, header, ctx) # speech bubbles + choices _: return Substory.new(id, runner, header, ctx) # plain narrativePattern: declare presentation mode in the story header, then route to a handler. One story format carries several on-screen treatments.
A line format + a command bus (the heart of scripted scenes)#
Each line the runner emits is classified by regex and routed:
$ command args --flags→ a Command (do something in the game)char_key(duration): text→ a dialogue line (show a speech bubble): text→ an inner thought (thought bubble)sender: message→ a chat message
# ConstantsREGEX_COMMAND := "\\$\\s+([a-zA-Z0-9._-]+)\\s*(.*)\\s*"REGEX_DIALOGUE := "(?'char_key'[\\w:-]+)\\s*(?:\\((?'duration'.+)\\))?\\:\\s*(?'text'.+)"Command bus = chain of responsibility#
Commands are not a giant match in one file. Any component can register a handler;
Story.run_command walks the handlers and the first to return a non-sentinel result
wins (a CMD_RETURN_NONE constant is the “not handled” signal). Handlers
register/unregister with tree lifecycle:
func _enter_tree(): Story.add_command_handler(_run_command)func _exit_tree(): Story.remove_command_handler(_run_command)func _run_command(cmd): match cmd.name: "play_sound_persist": _play(cmd.params[0]); return true _: return Constants.CMD_RETURN_NONE # let someone else handle itIn the shipped game dozens of components self-register: DialogueHandler owns say,
StateContainer owns flag/dict commands (F, D), AudioUtils owns sound commands, the
scene manager owns start_skit, and Story owns global ones (wait, change_scene,
unlock_ach…). Pattern: a command bus with chain-of-responsibility handlers. Adding a
new $ verb means a component registering a handler, never editing a central dispatcher.
This is the narrative-game analogue of the deck-builder’s hook pipeline.
Commands embedded inside dialogue text#
Beyond whole-line commands, dialogue text carries inline directives processed during the
character-by-character reveal: [....] (pauses), [$ emit paused] (run a command mid-line),
[>2.0] (speed multiplier). Pattern: inline markup for fine-grained narrative timing.
Presenting dialogue: state-machine bubbles + an event bus#
A SpeechBubble is driven by a small state machine (Hidden → RevealText →
PendingAdvance → Hidden; a parallel RevealChoices → PendingChoice branch). It resolves
where to appear by asking the Index for the character’s SpeechBubblePivot by alias
(3D-unprojected or 2D), and adapts to stay on-screen.
Choices flow back into the runner: runner.get_choices() → render → player picks →
runner.choose_choice_index(i) → continue. Timed choices push a countdown with a
fallback index for time-pressure beats.
Story-wide coordination uses a tiny event bus on the Story autoload: Story.emit(name)
and await Story.wait_for(name), so a scene can block until a narrative beat fires without
direct references. Pattern: dialogue UI as a state machine + a story event bus for
loosely-coupled sequencing.
Skits: embedded interactive scenes#
A SkitContainer (with an id, indexed) runs a sub-story scoped to its own subtree, resolving
pivots and commands locally unless allowed global access. Triggered from script
($ start_skit --wait WakeUp), it emits skit_started/skit_finished. Pattern: scope a
sub-story to a container so the same dialogue machinery drives small in-world vignettes.
The graph-dialogue family (when you don’t want a script language)#
The alternative to Ink: dialogue as a JSON node graph of typed nodes keyed by id, each
carrying next (or a branches map), interpreted by a walker. Same two seams as above, just
wearing different clothes. Distilled from a studied dialogue addon; core verified against the live engine.
The graph is data; the interpreter is a match. Flow control ships as node types, not
code: show_message (text per language: {"ENG": ...}, optional choices),
condition_branch (branches: {True, False}), chance_branch (weighted),
random_branch, repeat (loop N times via a counter dict, exit to next_done), wait,
set_local_variable, action. The walker is ~10 lines:
var node: Dictionary = nodes[next_id]match node.type: "show_message": show_message(node) # sets next from node.next or a choice "condition_branch": next_id = node.branches["True"] if evaluate(node.condition) else node.branches["False"] "action": execute_action(node.action_name, node.get("params", {})); next_id = node.nextWriters get non-linearity (chance/repeat/conditions) without touching code, and the format is
diffable, generatable, and migratable. Version the format: a version field, per-node
converters that upgrade old files on load, and a migration tool. Dialogue data outlives code.
Seam A: a safe evaluator, not eval. Conditions and @var@ text interpolation go through
Godot’s Expression class with an allowlisted context (the dialogue’s local variables plus a
few globals), so writers get gold >= 10 and not met_guide and never arbitrary code:
var expression := Expression.new()expression.parse(input, PackedStringArray(context.keys()))var result: Variant = expression.execute(context.values(), self)Seam B: an action registry. Dialogue triggers game effects only by name through registered
callables; namespaced variable providers ("player" → callable answering "health") expose
game state read-only. The graceful-degradation detail worth copying: an unregistered action
emits action_requested(name, params) instead of erroring, so a scene can handle one-off
actions by signal without registering globally. This is the graph-family’s command bus.
VN quality-of-life is a small, separable layer (players expect all four):
- Skip: hold-to-skip that only fast-forwards visited nodes. Keep a
"path:node_id"visited set, and never skip through choices. - Auto-advance: arm a one-shot timer only when the message finished revealing and no choice is pending; cancel on any manual input.
- Backlog: a capped ring buffer of typed entries (message vs choice, speaker, node id), searchable; feed it from the same place that shows messages.
- Resume: a
DialogueStateResource (current path + node id, variables, flags, visited, repeat counters). With it,resume_from_state()is just “load file,next_id = saved, step”.
Presentation: one Message contract, N skins. Box (bottom panel) and Bubble (speech balloon)
both implement show_new_message(text) / finish_message() + started/finished signals; each
node picks its skin (is_box), a global override forces one. Mechanics worth stealing:
- World-anchored bubbles: 2D follows
speaker.get_global_transform_with_canvas().origin; 3D followscamera.unproject_position(speaker.global_position). Same bubble, both worlds. - Typewriter over BBCode: pair an invisible
Label(gets the tag-stripped text, drives layout) with aRichTextLabel(gets the BBCode), and advancevisible_characterson both. - Measure-then-wrap: show the bubble with autowrap off, wait one frame, and only if it exceeds max width flip autowrap on and clamp, so short lines get tight bubbles.
- Conditional choices stay visible but disabled, with the unmet condition as tooltip. Players see the door they couldn’t open.
- A per-character
CharacterDataResource (portraits by emotion, name/text colors, voice blip pitch, preferred skin) in aCharacterDatabasekeeps styling out of the graph.
Chapter flow via a scene directory#
Scenes are keyed ("1.1a") and chained through a JSON SceneDirectory (chapters → ordered
scenes, per-playthrough variants, hidden/unlocked). to_next_scene() follows the chain;
crossing a chapter boundary shows a chapter title. See gdscript-architecture.md “Game
orchestrator”. Pattern: author the macro-flow as data, not as change_scene calls
scattered through scenes.
Data-driven in-world apps (the fake phone/PC OS)#
The social feed, web browser, email, and forum are entirely data-driven off the Store’s JSON databases, with no bespoke code per post.
-
Domain objects are live JSON proxies. A class wraps a dict from the Store; its properties get/set straight into that dict, so mutations persist with no explicit save:
var liked: bool:set(v): _dict.liked = v # writes through to the Store-backed dictget: return _dict.get("liked", false) -
UI binds from data. A feed screen reads
Store.get_value("fb://posts/<id>"), wraps it in a domain object, instances a view per item. A like button just flips the proxy property; a dirty-checker re-renders. -
References as strings (
author_ref = "fb://profiles/alice") model the social graph and lazy-load via the Store, which buys circular relationships without deep copies. -
URLs parse to Store paths (
https://site/2014/11/article→ a nested lookup), so a “web” is just addressable data, and acontent_typefield selects which view scene to instance (text / picture / album / video) through one pipeline.
Player actions mutate the proxies → the Store diffs them per scene (see the two-tier save
in gdscript-architecture.md), so the world remembers what you liked or read.
Pattern: in-world apps = JSON databases + proxy domain objects + view-per-item binding.
Content authors add posts/emails by editing JSON, not code.
The product shell (everything around the story)#
Mined from a VN engine’s starter game; the save/settings core validated against the live engine. The shell (boot, title, saves, settings, extras) is where “demo” and “shippable” diverge, and it generalizes far beyond VNs. The organizing idea: the shell is data-driven from manifests, so re-skinning the product never touches code.
- One project manifest (a TOML file:
[project],[boot],[title],[window]) drives the whole shell: window title, splash entries, which scenes are title/settings/extras, music. A ~100-line TOML-subset parser (sections, scalars, flat arrays) is all it takes; parse once at boot and stash on the tree (get_tree().set_meta) for every screen to read. - Boot flow: play splash images/videos from the manifest. With none configured, fall back to procedural text cards built from the project’s own metadata (studio name, title), so the flow is complete on day one. Fade in → wait-or-skip → fade out → a short black dwell between cards (cuts without it read as flashes). Skip is one flag checked by every wait.
- Saves: version + wrap, never introspect. The payload is
{version, slot_id, saved_at_unix, snapshot_json, custom_state, custom_meta}wheresnapshot_jsonis the story runner’s own opaque state export, so the save system never knows story internals. External systems register as providers contributing custom state under namespaced keys (with key-collision detection). Before overwriting a slot, copy it to.bak; loads that fail fall back to the backup, and every failure path returns a named reason (slot_not_found,backup_write_failed) surfaced by signal, never a crash. Slot UI is powered bylist_slots_metadata(): enumerate, validate, sort by recency. - Settings are a table, not a screen of code. Each setting is one registration row
(
key, control, kind, options, default, section); load/apply/persist (ConfigFile) iterate the table. Four tabs is the shipped shape: General, Display, Sound, Accessibility (subtitle size/background, colorblind overlay, QTE-off). Plan the a11y tab from the start. - Chapter select and extras read TOML too:
episodes.toml/chapters.tomlfeed the episode carousel + scene gallery;extras.tomlis a card grid (thumbnail, badge, lock state, target scene or URL). Content unlocks are data rows, not screens. - Give every screen a tiny
ScreenBase(fade-in, back navigation, manifest access) so screens stay declarative.
Localize narrative content, not just UI strings#
Two layers: Godot tr() + .translation files for chrome (menus, settings), and
per-locale data files for narrative, meaning localized .inkb under locales/<lang>/ and
localized JSON DBs (state_ja.json, fb_ja.json), each falling back to the default
locale. A locale_changed signal makes the store reload all databases live. Remember
font remapping per locale for CJK. Pattern: localize the data layer with
default-locale fallback, and reload state on locale change.
Transferable rules#
- Author branching story in a script language (Ink) run through a runner; GDScript only drives + renders.
- Bridge script→state with auto-bound external functions into a central store.
- Declare presentation mode in the story header tags; route to a handler per mode.
- Classify emitted lines by regex into commands vs. dialogue vs. chat vs. thought.
- Dispatch
$ commandsthrough a chain-of-responsibility bus; components self-register handlers. - Allow inline command markup in dialogue text for reveal timing.
- Drive dialogue UI with a state machine; resolve speaker anchors via the node Index.
- Coordinate beats with a story event bus (
emit/await wait_for), not direct refs. - Author chapter macro-flow as a keyed scene directory, not scattered
change_scenecalls. - Build in-world apps as JSON data + proxy objects + view-per-item; localize the data layer with fallback.
- Or skip the script language: dialogue as a versioned JSON node graph + a
matchinterpreter; flow control as node types. - Gate writer-facing logic behind a safe
Expressionevaluator and an action registry (unhandled → signal, never error). - Ship the VN QoL layer: visited-gated skip, choice-aware auto-advance, backlog, resumable
DialogueState. It’s small and players expect it. - One Message contract, N skins (box/bubble); anchor bubbles via canvas transform (2D) or
unproject_position(3D). - Drive the product shell from manifests (project/episodes/extras as TOML); saves wrap an opaque story snapshot with version +
.bakfallback + named failure reasons; settings are a registration table with an Accessibility tab from day one.