Unity
Patterplay for Unity is the native C# Patterplay runtime: no web view, no JavaScript, no
IPC. It loads a .patterc bundle and plays it directly, held to the
same shared test suite as every other engine.
Verified on Unity 6000.x. Some C# is expected, this is the game-developer side of the project.
Install
Section titled “Install”Patterplay ships as a UPM package and must be installed as a package (its Newtonsoft
Json dependency only resolves then). Get it from the play-unity-v* Release (see the
downloads page), any of:
- Unzip the release, then open Package Manager ▸ Install package from disk… and pick the
Patterplay/folder’spackage.json. - In Package Manager ▸ Add package from git URL…, paste the package’s git URL (the Release lists it).
- Copy the zip’s
Patterplay/folder into your project’sPackages/directory using your file browser, which embeds the package in the project.
Don’t drag the folder into the Unity Project window: even dropped onto its Packages
section, Unity imports it into Assets/ as loose scripts, where the package manifest is
ignored and the Newtonsoft dependency never installs (a wall of
Newtonsoft could not be found errors). Only the file browser or Package Manager can
install a package.
Import a story
Section titled “Import a story”A compiled .patterc is recognised by a ScriptedImporter: drop the file into your project
and Unity converts it to a PatterBundleAsset (with a custom inspector). Reference that
asset wherever you build an engine.
Play a flow
Section titled “Play a flow”Build an engine from the bundle asset, open a flow, and advance it. The play loop is the same shape as everywhere else: here it is in C#:
using UnityEngine;using Patterkit.Patterplay;
public sealed class StoryRunner : MonoBehaviour{ public PatterBundleAsset Bundle; // the imported .patterc
void Start() { var engine = Bundle.CreateEngine(); PatterDebug.Register(engine); // optional: lets the state window watch it var flow = engine.OpenFlow("main", "intro"); // ("flow id", starting scene/block)
for (;;) { var step = flow.Advance(); switch (step.Type) { case StepType.Line: Debug.Log($"{step.CharacterName ?? step.Character}: {step.Text}"); break; case StepType.Text: Debug.Log(step.Text); break; case StepType.GameEvent: /* play step.GameData cues */ break; case StepType.Choice: var pick = step.Options[0]; // your UI chooses; demo takes the first flow.Choose(pick.Id); break; case StepType.End: Debug.Log($"[end] @gold = {engine.GetProperty("@gold")}"); return; } } }}Drop that on a GameObject, assign the imported bundle to the Bundle field, and press Play.
Render each step into your own dialogue UI; on a Choice, show step.Options (each has a
Prompt and an Eligible flag) and call flow.Choose(id) with the player’s pick. A line’s
Qualifier is its speaker qualifier by its gameId (vo, os,
radio), and QualifierName its localised shown name; a line prompt carries both too. A Line or
Text step’s PadAfter (a double?, null on the other steps) is the writer’s pause after it in
seconds, already resolved; Bundle.DefaultPadAfter (0.6) is the one a project gets when it sets
none. The timing rules say how to play it.
Two ready-made samples ship with the package. Import them from Package Manager ▸ Patterplay ▸
Samples. Each carries a ready-made scene, so there is nothing to set up: import, open the
scene, press Play. The Tour demo (Tour.unity) plays the full interactive Patter tour as an
OnGUI transcript with clickable choices; the minimal Play-through demo (PlayThrough.unity)
is the smallest possible integration to read first. The tour sample also shows per-line audio
resolution via PatterAudioResolver; audio files are not bundled (playback is your platform
call), so point its Audio Root at a Patter audio folder to hear it, or leave it empty to
play silently.
Your game’s state
Section titled “Your game’s state”Hand the engine your @world values through EngineOptions.HostScopes, an IHostScope per
token (Get / Set, keyed by property name) that the story reads before every condition and writes
through on an effect. Bind the same object to anything else that shares those values:
using Wildwinter.Expr; // ExprValue, the value type
sealed class WorldScope : IHostScope{ public readonly Dictionary<string, ExprValue> Values = new() { ["time_of_day"] = ExprValue.Str("night") }; public ExprValue Get(string name) => Values.TryGetValue(name, out var v) ? v : null; // null = unset public void Set(string name, ExprValue value) => Values[name] = value;}
var world = new WorldScope();var engine = Bundle.CreateEngine(new EngineOptions { HostScopes = new() { ["world"] = world } });Each binding is registered as a foreign scope: the values stay in your object, and no Patterplay
save holds them, so your game saves them. Leave HostScopes null 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 '@world.x' is read-only, bound or self-backed. Your own SetProperty
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.
One registry per game
Section titled “One registry per game”Every property value lives in a registry, a ScopeRegistry. A game has one, and it holds every
property from every engine in the game, except values your game keeps itself behind an IHostScope.
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.SerializeState 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 EngineOptions.Registry:
using Newtonsoft.Json.Linq;using Wildwinter.Expr; // ScopeRegistry, ExprValue
var registry = new ScopeRegistry().DefineOwned("world", new[]{ new ScopeDeclaration { Name = "gold", Type = "number", Default = ExprValue.Num(0) },}, new OwnedScopeOptions { Owner = "Game" }); // @world, stored and savedvar patter = Bundle.CreateEngine(new EngineOptions { Registry = registry });
// One save for the game: the registry's values once, and each engine's part.var save = new JObject{ ["registry"] = PatterSave.SaveRegistry(registry), ["patter"] = PatterSave.SaveState(patter),};
// Load in either order: values for bags that aren't open yet wait in the registry.PatterSave.LoadRegistry(registry, (JObject)save["registry"]);PatterSave.DeserializeState(patter, save["patter"].ToString());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 HostScopes, when your game keeps the values. The engine
self-backs nothing on a registry you pass. Every expression can read every registered scope.
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: the compiler lets it through
without checking its names, since the Storylet Engine owns them, and the bundle lists it. Give every
engine the game’s one registry: OpenFlow and LoadGame refuse content that names a scope no engine
on the registry has registered, before anything changes, with the error this content names @story, which no engine on this registry registered: give every engine the game's one registry. If the other
engine takes its scope away mid-game, a write to it fails naming the scope rather than landing in
@patter.
A token is taken once. Two engines that both want the same one fail as you build the second, with an
error that names who got there first, and your registry is left as it was. Rebuilding an engine on an
edited bundle (HotSwap) hands its bags to the replacement on the same registry.
The registry, the value type ExprValue, and the rest of the shared expression kernel live in the
Wildwinter.Expr namespace, in their own assembly, Patterplay.Expr. The Storylet Engine carries the
same kernel, and with both packages installed it compiles once, in Patterplay, so one ScopeRegistry
is the same type to both engines. The Storylet Engine then needs Patterplay 0.14.0 or newer, and stops
the compile with an error saying so if it finds an older one. If your scripts have their own assembly
definition, reference Patterplay.Expr and StoryletEngine.Expr beside Patterplay.Runtime: Unity
ignores whichever is not installed. The engine’s own errors are still EvalError; a call you make
straight to the registry throws the kernel’s RegistryError.
Send the story somewhere
Section titled “Send the story somewhere”The game can also decide where the story goes. RunFlow 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 placeList<StepResult> lines = engine.RunFlow("guard-42", "npc-barks", "greet");foreach (var line in lines) Debug.Log($"{line.CharacterName ?? line.Character}: {line.Text}");
// Or move a flow you are already driving, exactly as an authored jump wouldif (!flow.Goto("throne-room", "audience")) { /* did not resolve; the cursor has not moved */ }Give each independent speaker its own flow name. Full rules, and why OpenFlow behaves
differently: Host navigation.
Inspect live state
Section titled “Inspect live state”The package adds Window ▸ Patterplay ▸ Runtime State: register a running engine with
PatterDebug.Register(engine) and the window shows its @patter properties live, with
type-aware editors (toggle / number / text / enum / flags) and a reset-to-default arrow that
write straight back into the running game. It also has Save State… / Load State… buttons
that persist the whole run to a .patterstate JSON file. The window is an editor-only tool (it
lives in an Editor assembly), so it never ships in a player build.
Follow the live cursor in Patterpad
Section titled “Follow the live cursor in Patterpad”PatterDebugLink streams the running story position back to Patterpad so the editor follows the
cursor like a debugger. Wire it behind #if UNITY_EDITOR || DEVELOPMENT_BUILD so it is stripped
from a release player build:
#if UNITY_EDITOR || DEVELOPMENT_BUILD_link = new PatterDebugLink(engine.BuildId, "My Game");_link.FlowOpened("main");// ...after each Advance()/Choose():_link.Observe("main", flow.CurrentScene, step.Id, PatterDebugLink.TypeName(step.Type));#endifThe protocol and the editor side are on Live refresh & debug.
Save and load
Section titled “Save and load”PatterSave.SerializeState(engine) and DeserializeState(...) round-trip the whole run: every
flow’s position, visit counts, selector cursors, and the PRNG, 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. 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 Godot 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
package before 0.11.0 (its old PascalCase shape) still load too, and are written back in the shared
shape on the next save. Persist the string wherever you keep saves, and see
Save/load & Game Data for the format.
- The play loop is the shared model.
- Host navigation drives the story from the game.
- Save/load & Game Data covers Game Data, tags, host events, and localisation.
- Compatibility & conformance explains why it matches the other engines exactly.
Open source under the MIT licence, made by Ian Thomas.