Storylet Studio

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

  1. Download StoryletEngine-Godot-<version>.zip from your project's downloads site (or the Godot Asset Library, when listed).
  2. Extract the archive. Inside you'll find a StoryletEngine/ folder.
  3. Copy that folder into your Godot project's res://addons/ directory so the final path is res://addons/StoryletEngine/.
  4. 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 a play / set_property_by_name.
  • action_log_changed() - fires after a play when the session was created with enableLog = true.
  • site_hands_changed() - fires after draw / play / reset so 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 when draw() hits the dead-state guard, or play() rejects an unknown storylet / outcome or an unavailable gated outcome. The typed sibling ports raise EngineError here; 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 like storylet / outcome. draw() and play() also return the diagnostic on their result Dict as error (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:

  1. Declare a flags-typed property (world or scoped) to hold the open flags. Add each open/closed flag by name.
  2. Add a prompts entry per flag in your storyworld: the flag name + the journal title (and optional description + condition-gated text variants).
  3. 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 on flag + optional variantGameId).
  • 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).