Skip to content

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.

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’s package.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’s Packages/ 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.

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.

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.

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.

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 saved
var 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.

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 place
List<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 would
if (!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.

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.

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));
#endif

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

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.

Open source under the MIT licence, made by .