StoryletEngine for Godot
This is the end-user quickstart for the Godot port of StoryletEngine. For the architecture + maintainer notes see ../developer/godot-plugin.md.
Requirements
- Godot 4.4 or newer. Typed dictionaries + static-typing performance opcodes are load-bearing.
- Works in both the standard and .NET editions - the plugin is pure GDScript, no C# assemblies.
Installation
- Download
StoryletEngine-Godot-<version>.zipfrom your project's downloads site (or the Godot Asset Library, when listed). - Extract the archive. Inside you'll find a
StoryletEngine/folder. - Copy that folder into your Godot project's
res://addons/directory so the final path isres://addons/StoryletEngine/. - In Godot: Project > Project Settings > Plugins, tick the StoryletEngine checkbox.
Godot will register the .storyworld import plugin. Drag any
.storyworld file into your project's FileSystem dock and Godot will
auto-import it as a StoryworldResource.
Basic usage
From a scene
Add a StoryletSessionNode to your scene, assign the imported
.storyworld to its storyworld field, connect its signals, and
drive it from scripts.
extends Node
@export var session: StoryletSessionNode
func _ready() -> void:
session.property_changed.connect(_on_property_changed)
session.site_hands_changed.connect(_on_site_hands_changed)
# Draw a hand from the current state.
var result: Dictionary = session.draw({"scope": "global"})
for card in result["hand"]:
print(card["storyletGameId"])
func _on_property_changed(key: String, new_value: Variant) -> void:
print("%s is now %s" % [key, new_value])
func _on_site_hands_changed() -> void:
# Refresh map / pin / overlay widgets.
pass
From script only (no scene)
extends Node
func _ready() -> void:
var res: StoryworldResource = load("res://my_world.storyworld")
var session: StoryletSession = StoryletSession.from_resource(res, {
"randomSeed": 42,
"enableLog": true,
})
var hand: Array = session.draw({"scope": "global"})["hand"]
if not hand.is_empty():
var first := hand[0]
session.play(first["storyletGameId"], "default")
print("turn is now %d" % session.get_turn())
Session API
| Method | What it does |
|---|---|
draw(query: Dictionary) |
Roll a hand. query.scope is "global", "zone", or "site" (with zoneId / siteId). Returns {"hand": Array, "context": Dictionary, "error": null | Dictionary} — the error is populated only when the dead-state guard fires; also delivered via the error_occurred signal. |
play(storylet_gid, outcome_gid, options?) |
Play an outcome, apply its changes, advance the turn. Returns true on success, false on any engine error (unknown storylet / outcome / unavailable gated outcome). Errors are also delivered via the error_occurred signal. options may include siteGameId / zoneGameId for scoped-property writes. |
advance_turn(amount = 1) |
Advance time without playing. |
set_property_by_name(name, value) |
Write a world property by its gameId. |
get_property(name) |
Read a world property by its gameId. |
get_turn() |
Current turn counter. |
open_prompts() |
Currently-open quest-log entries (see below). Pure; safe to call after every context_changed. |
save_snapshot() / restore_snapshot(dict) |
Versioned save/restore of the whole context. |
save_snapshot_json() / restore_snapshot_json(text) |
Same, as JSON text for persistence. |
reset(options?) |
Start over with a fresh context. |
Signals
Emitted on the StoryletSession and re-emitted on StoryletSessionNode:
property_changed(key: String, new_value: Variant)- fires per world property whose value shifted after aplay/set_property_by_name.action_log_changed()- fires after aplaywhen the session was created withenableLog = true.site_hands_changed()- fires afterdraw/play/resetso map / pin / overlay widgets can refresh.context_changed()- bulk signal fired after every mutating call; useful for one-shot refresh subscribers.error_occurred(error: Dictionary)- fires whendraw()hits the dead-state guard, orplay()rejects an unknown storylet / outcome or an unavailable gated outcome. The typed sibling ports raiseEngineErrorhere; GDScript has no exceptions, so the diagnostic is delivered via signal. Payload keys:kind("dead_state"/"unknown_storylet"/"unknown_outcome"/"outcome_unavailable"),message(human-readable), plus per-kind fields likestorylet/outcome.draw()andplay()also return the diagnostic on their result Dict aserror(null on success) so callers can inspect it synchronously.
Editor dock
Once the plugin is enabled, a StoryletEngine dock appears in the
Godot editor (left-bottom-right slot by default; drag the tab
anywhere). Click Pick a .storyworld resource..., select any
imported .storyworld file, and the dock shows the bundle summary
(storyletsVersion, storylet count, world + scoped property counts,
container gates in flight) plus a property grid (gameId / type /
default value) - useful for eyeballing a bundle without opening the
JSON.
The dock is authoring visibility only; it doesn't attach to a live game session (Godot editor + game run in separate processes). For live state, use the in-game debug overlay below.
Quest-log prompts
The engine exposes a "quest log" surface built on flags properties:
- Declare a
flags-typed property (world or scoped) to hold the open flags. Add each open/closed flag by name. - Add a
promptsentry per flag in your storyworld: the flag name + the journal title (and optional description + condition-gated text variants). - Set / clear flags in outcome changes with
set_flags(+flag)/set_flags(-flag)as normal. The prompt opens when the flag is set, closes when it's cleared.
Read the currently-open journal:
for entry in session.open_prompts():
print(entry["title"])
if entry.has("description"):
print(" ", entry["description"])
Each returned entry carries:
propertyId,scope,flag- stable identity (a localising host keys onflag+ optionalvariantGameId).ownerId/ownerGameId/ownerName- the owning container for scoped bags (absent for world scope).title/description- the resolved text: the active variant's, else the base entry's.variantGameId- present when an active text variant supplied the wording, absent when the base text is showing.
Refresh after any property change - typically on context_changed.
Reads are pure and never advance the session's PRNG, so it's cheap
to poll or event-drive.
Debug overlay
The addon ships a StoryletStatePanel (extends PanelContainer) - an
in-game debug overlay that watches a live StoryletSessionNode and
shows the current turn / seed / active sites, the world properties
grid, the last drawn hand, and the tail of the action log. Because a
Godot game runs in its own OS process (unlike Unity Play mode), the
useful inspector lives in the game world, not the editor dock.
Drop it in your scene alongside the session:
var panel := preload("res://addons/StoryletEngine/ui/storylet_state_panel.gd").new()
panel.session_node = my_storylet_session_node
add_child(panel)
# Optional: forward drawn hands so the panel can render them.
var result := my_storylet_session_node.draw({"scope": "global"})
panel.set_last_hand(result["hand"])
The panel refreshes on the session's context_changed signal (fast
path) plus a half-second poll for correctness. It stays hidden in
release exports by default (debug_only = true), so you can leave it
wired into a shipped scene without worry - set debug_only = false on
the panel if you want it live in release.
Riverbend Inn sample
The port ships the same Riverbend Inn walkthrough the Unity,
Unreal, and JavaScript ports ship - byte-identical bundle, same three
storylets, same two sites, same three properties. It lives inside the
addon at res://addons/StoryletEngine/samples/RiverbendInn/, so a
single addon install ships it. Open RiverbendInn.tscn and press
Play; the driver script prints a keymap on start (1 = list hands,
2 / 3 = play at each site, 4 = show world properties). See the
sample's own README for the walkthrough.
Cross-runtime notes
The Godot port runs the same .storyworld bundles the TypeScript
reference, Unity C#, and Unreal C++ ports do. Every port passes the
same cross-runtime conformance suite at
packages/engine/test-suite/shared-suite.storyworlds, so a bundle
that works in one runtime works in all four with identical outcomes.
Performance notes
GDScript is interpreted and typically slower than the Unreal or Unity ports. Storylet draw / play is not a per-frame hot path so most games will not notice, but expression-heavy evaluation over very large worlds may cost more than on the native ports.
The addon precompiles a lookup-index cache at bundle load time (matching
what the typed sibling ports build in StoryWorld / FStoryWorld), so
gameId → id resolution, per-container scoped-property lookups, and
storylet lookups by gameId are all O(1) inside draw / play / open_prompts
- no scans of the underlying arrays on each call. A rough benchmark on a 200-storylet / 30-world-property bundle draws in ~2.4 ms per call on a modern developer machine; scales linearly with storylet count.
If you hit a bottleneck: run your world through the Unity port's editor for a comparison - if the Unity port also struggles, the fix is in the storyworld design (deep priority expressions, dead-state gates, etc); if only the Godot port struggles, file an issue with a reproduction bundle.
Where to next
- The addon's own
README- quickstart, full API reference, supported Godot versions, troubleshooting. - For Game Developers - the overall publish-and-load workflow, how the engines compare, and licensing / source.
- Simulate - what running a storyworld looks like in the reference UI; the Godot addon exposes the same Draw / Play primitives.
- Game Data - the per-storylet custom fields a player shell typically reads to drive presentation (UI, dialogue, audio).