Skip to content

Godot

Patterplay for Godot is the pure-GDScript Patterplay runtime: no web view, no native extension to compile. It loads a .patterc bundle and plays it directly, held to the same shared test suite as every other engine.

Needs Godot 4.4 or newer; verified on 4.7, which every release is gated on. The runtime uses only plain GDScript (no scene-tree types), so it also runs headless.

Drop the addons/patterplay/ folder into your project’s addons/ directory (download it from the play-godot-v* Release: see the downloads page) and enable the plugin in Project ▸ Project Settings ▸ Plugins. The runtime works with or without the editor plugin enabled; enabling it just registers the helper classes.

Load the bundle text, build an engine, open a flow, and advance it. The play loop in GDScript: steps come back as plain dictionaries:

var json := FileAccess.get_file_as_string("res://story.patterc")
var bundle = PatterBundle.load_from_string(json)
var engine := PatterEngine.new(bundle)
var flow := engine.open_flow("main", "intro") # ("flow id", starting scene/block)
while true:
var step := flow.advance() # { "type": ..., "text": ..., "options": ... }
match step["type"]:
"line": print("%s: %s" % [step.get("characterName", ""), step["text"]])
"text": print(step["text"])
"gameEvent": pass # fire step.gameData cues
"choice": flow.choose(step["options"][0]["id"]) # your UI picks
"end": break

Render each step into your own dialogue UI. A "choice" step carries the choice’s groupId and its options, each { "id", "prompt", "eligible", "gameData" } (prompt and gameData only when set): present each option’s prompt["text"], greyed when eligible is false, and call flow.choose(id) with the player’s pick. The prompt is { "kind": "line" | "text", "text" }, and a "line" prompt also carries the speaker’s character, characterName, direction, qualifier, and qualifierName when set, the same shape every Patterplay runtime gives. A "line" step carries qualifier and qualifierName the same way: the line’s speaker qualifier by its gameId, and its localised shown name. A "line" or "text" step always carries "padAfter", the writer’s pause after it in seconds, already resolved; the timing rules say how to play it.

open_flow returns null, with push_error, when the address does not resolve: an unknown scene, or a block that is not in the scene you named. Nothing opens, and a flow already open under that name carries on as it was.

Two demos ship inside the addon, under addons/patterplay/demo/ (delete the folder freely): a headless play-through demo (demo.gd, the smallest possible integration) and the Tour scene (tour.tscn), which plays the full interactive Patter tour with clickable choice buttons. The tour also shows per-line audio resolution via PatterAudioResolver; audio files are not bundled (playback is your platform call), so point its audio base at a Patter audio folder to hear it, or leave it unset to play silently.

Hand the engine your @world values through the host_scopes option, a get / set pair of Callables per token, keyed by property name, that the story reads before every condition and writes through on an effect. Bind the same store to anything else that shares those values:

var world := {"time_of_day": "night", "knows_road": false}
var engine := PatterEngine.new(bundle, {"host_scopes": {"world": {
"get": func(n): return world.get(n), # null = unset
"set": func(n, v): world[n] = v,
}}})

Each binding is registered as a foreign scope: the values stay in your store, and no Patterplay save holds them, so your game saves them. Leave host_scopes out and a standalone engine self-backs @world from the declared defaults, as a property it stores and saves with the rest of the run. A property declared writable: false in the project is the story’s promise, so the engine refuses the story’s write with a push_error ('@world.x' is read-only) and no write, bound or self-backed. Your own set_property isn’t refused, because the value is the game’s. A per-name policy of your own is yours to refuse from set. World Properties has the full picture.

Every property value lives in a registry, a PatterScopeRegistry. A game has one, and it holds every property from every engine in the game, except values your game keeps itself behind host_scopes. The registry is saved and loaded as one.

An engine you build without one makes its own and acts as its own game, which is why a single PatterSave.serialize_state needs no wiring. A game that runs more than one engine (Patter beside the Storylet Engine, say), or that wants to hold the properties itself, makes the registry and hands it to each engine through the registry option:

var registry := PatterScopeRegistry.new()
registry.define_owned("world", [{"name": "gold", "type": "number", "default": 0}],
{"owner": "Game"}) # @world, stored and saved
var engine := PatterEngine.new(bundle, {"registry": registry})
if engine.init_error() != "":
push_error(engine.init_error()) # a token clash: see below
# One save for the game: the registry's values once, and each engine's part.
var json := JSON.stringify({"registry": registry.save(), "patter": PatterSave.save_state(engine)})
# Load in either order: values for bags that aren't open yet wait in the registry.
var data: Dictionary = JSON.parse_string(json)
registry.load(data["registry"])
PatterSave.load_state(engine, data["patter"])

Given a registry, the engine registers @patter under patter and each flow’s and scene’s bag under a key starting patter/, which no expression can name. Its save then leaves the values out, because your game saves the registry. @world is yours to register: owned, as above, when the registry should store and save it, or bound through host_scopes, when your game keeps the values. The engine self-backs nothing on a registry you pass. Every expression can read every registered scope, so a condition can test another engine’s @story.act once that engine is in the same registry, and engine.get_property("@story.act") reads it too.

Other engines’ scopes need no setting. A Patter line can name the Storylet Engine’s @story.act (in a condition, an effect, or a {@story.act} slot) in any project, and the bundle records it in externalScopes (PatterBundle.external_scopes(bundle) reads it). That content runs only where the other engine is on the same registry. If it is not, open_flow and load_game refuse before anything changes: each push_errors this content names @story, which no engine on this registry registered: give every engine the game's one registry, then open_flow returns null and load_game returns false. If that engine takes its scope away mid-game, a write to it fails in the registry naming the scope (a push_error, unknown scope '@story') rather than landing in @patter.

A token is taken once. Two engines that both want the same one clash as you build the second: GDScript has no exceptions, so the registry push_errors a message naming who got there first, the engine’s init_error() returns it, the new engine is inert, and your registry is left as it was. Values a load left for a flow that never reopened stay in the registry, and in its next save, until you call registry.discard_parked(). Rebuilding an engine on an edited bundle (hot_swap) hands its bags to the replacement on the same registry.

The game can also decide where the story goes. run_flow plays an address in one call, which is all a bark needs:

# Reuses the "guard-42" flow, so its shuffles and once-each lists keep their place
var lines: Array = engine.run_flow("guard-42", "npc-barks", "greet")
for line in lines:
print("%s: %s" % [line.get("characterName", ""), line["text"]])
# Or move a flow you are already driving, exactly as an authored jump would
var moved: bool = flow.goto("throne-room", "audience") # false = did not resolve, cursor unmoved

Give each independent speaker its own flow name. Full rules, and why open_flow behaves differently: Host navigation.

A Godot game runs in its own process, so the live state inspector ships as an in-game overlay, PatterStatePanel, it watches and edits a running engine’s @patter properties and saves / loads the whole run:

PatterDebug.register(engine) # right after you build the engine
var panel := PatterStatePanel.new() # auto-discovers registered engines
add_child(panel) # (or set panel.engine = my_engine)

Each property gets a type-aware editor (bool / number / string / enum / flags) with a reset-to-default button; values live-refresh without clobbering the field you’re editing. The panel is a debug tool: in a release export (OS.is_debug_build() false) it stays hidden and builds nothing, so it is safe to leave in a scene that ships.

PatterDebugLink streams the running story position back to Patterpad so the editor follows the cursor like a debugger. It only opens the link in a debug build, so it is inert in a release export:

var link := PatterDebugLink.new(engine.build_id(), "My Game")
add_child(link)
link.flow_opened("main")
# ...after each advance()/choose():
link.observe("main", flow.current_scene(), step.get("id", ""), step["type"])

The protocol and the editor side are on Live refresh & debug.

PatterSave.serialize_state(engine) and deserialize_state(engine, json) round-trip the whole run: every flow’s position, visit counts, selector cursors, and the seeded random generator, as a tagged JSON envelope. An engine you built on its own also carries every property value, @patter, @scene, and a self-backed @world alike, so one call is still the whole game; an engine you gave a registry leaves them to the registry’s save. deserialize_state returns false, with a push_error, for a blob it cannot read. It is the same patter/save@0 format every Patterplay runtime uses, so a save written by a web build or by Patterpad loads here, and a save written here loads in Unity or Unreal. A save written before property values moved into the registry (version 2) still loads, and its values move into the registry as it does. Saves written by this addon before 0.11.0 (its old snake_case shape) still load too, and are written back in the shared shape on the next save. Save/load & Game Data covers the format.

Nothing to configure. From Patterplay 0.4.5 the plugin puts your .patterc into the export itself, so an exported build has its story on every platform (desktop, mobile, and web). Export as you would any Godot project.

That is worth stating plainly because it used to be a trap. Godot packs the files it recognises as resources and can silently drop everything else, so a game ran perfectly in the editor, which reads loose project files, and shipped without its story. If you followed an older version of this page and added *.patterc to your export filters, you can leave it: it is harmless, and it still covers you if the plugin is ever disabled.

Other loose files are still yours to handle. The plugin knows about bundles and nothing else, so if you use Audio Folders add patteraudio.json under Project ▸ Export… ▸ your preset ▸ Resources ▸ “Filters to export non-resource files/folders”, since the audio files themselves are imported resources and export fine, while the manifest is plain JSON and isn’t. The same goes for any other data file you read at runtime, like a *.json save template.

To sanity-check a build before you ship it, in the EXPORTED game rather than the editor:

print(FileAccess.file_exists("res://story.patterc")) # must print true

Open source under the MIT licence, made by .