Unreal
Patterplay for Unreal is the native C++ Patterplay runtime, wrapped in a Blueprint- and
C++-friendly plugin. It loads a .patterc bundle and plays it
directly: same bundle, same behaviour, held to the same
shared test suite as every other engine.
Verified on Unreal Engine 5.7.4 and 5.8.3, and builds from source against any 5.7 or 5.8 release (it is not locked to one point version). Usable from C++ or Blueprint; some engineering is expected.
On a Mac, Unreal 5.7 accepts Xcode only up to 26.9. With Xcode 27, use Unreal 5.8, or keep an Xcode 26.x beside it and point 5.7’s build at that one.
Install
Section titled “Install”The release zip (from the play-unreal-v* Release: see the
downloads page) contains two sibling folders: the Patterplay/
runtime plugin, and PatterplayDemo/, a ready-to-open sample project. To try
Patterplay first, just open PatterplayDemo.uproject where it sits, and it finds the plugin in
the sibling folder, nothing to install. To use it in your game, drop Patterplay/ into your
project’s Plugins/ folder, restart the editor, and enable it. Everything ships
source-only. The runtime core is header-only standard C++, so it compiles inside your
project with no extra dependencies (a C++ project is required).
Import a story
Section titled “Import a story”A compiled .patterc is imported by the plugin’s factory and becomes a UPatterBundle
asset in your content browser. Reference that asset wherever you build an engine.
Play a flow (C++)
Section titled “Play a flow (C++)”The play loop in Unreal terms: build an engine, open a flow, advance
it, present each FPatterStep:
UPatterEngine* Engine = UPatterEngine::Create(Bundle); // Bundle: a UPatterBundle*UPatterFlow* Flow = Engine->OpenFlow(TEXT("main"), TEXT("intro"));
FPatterStep Step = Flow->Advance(); // Step.Type, Step.Text, Step.Character, Step.Options// Render Step by its kind (line / text / game event / choice / end). On a choice,// present Step.Options (each has its Prompt, with its Kind and Text, and an eligibility flag), then:Flow->Choose(Step.Options[0].Id); // your UI chooses; here, the first optionThe same UPatterEngine / UPatterFlow API is exposed to Blueprint, with FPatterStep and
FPatterOption as Blueprint structs, so a designer can drive the flow and bind steps to a
dialogue widget without touching C++. It is the API the other runtimes have, under the same names:
a flow’s GetChoices, GetProperty and SetProperty (its @scene values included), Log,
Interpolate and StripCaptions; and the engine’s Flows, addresses, tags, Game Data, and
OpenFlow with a block and a seed. A step’s bHasCharacter, bHasCharacterName,
bHasDirection, bHasQualifier, and bHasQualifierName tell a field that isn’t set from one that is
empty. Qualifier is the line’s speaker qualifier by its gameId
(vo, os, radio), and QualifierName its localised shown name; a line prompt carries both too.
PadAfter is the writer’s pause after a line or text step, in seconds, already resolved. It’s always
set on a Line or Text step and 0 on any other, so it has no bHas flag. The
timing rules say how to play it.
To choose how the engine plays, create it with UPatterEngine::CreateWithOptions and an
FPatterEngineOptions: a seed for a repeatable run, the locale to play in, a decision log (read it
with Log), whether a chosen option’s prompt is spoken back, and whether closed captions start on.
The engine’s OnDryChoice event fires whenever a choice has nothing left to offer.
FPatterEngineOptions Options;Options.Locale = TEXT("fr");Options.bLog = true;UPatterEngine* Engine = UPatterEngine::CreateWithOptions(Bundle, Options);The PatterplayDemo sample project (the second folder in the release zip) holds two working
references. Press Play in it and ATourDemoActor runs the complete interactive Patter
tour in a UI overlay (a scrolling transcript with clickable choices), loading its bundle from
disk, so a fresh unzip plays with no setup. APatterplayDemoActor is the minimal shared
demo flow (the smallest render-and-choose loop, logged) to read first. The tour actor also
shows per-line audio resolution via UPatterAudioResolver; 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”Give the engine your @world values in a UPatterWorld when you create it. The story reads
them through it before every condition, effects write back into it, and anything else you bind to
the same object, your own systems or the Storylet Engine’s UStoryletWorld, sees the same values:
UPatterWorld* World = NewObject<UPatterWorld>(this);World->SetString(TEXT("time_of_day"), TEXT("night"));World->SetBool(TEXT("knows_road"), false);World->SetReadOnly(TEXT("time_of_day"), true); // yours alone: a story write is refusedUPatterEngine* Engine = UPatterEngine::Create(Bundle, World);
World->OnChanged.AddDynamic(this, &AMyActor::OnWorldChanged); // (Name, Value, bFromStory)Everything on UPatterWorld is Blueprint-callable, with typed Set* / Get*, Has, Names,
SetReadOnly, and an OnChanged delegate that tells your own writes from the story’s. Names match
case-insensitively, as the story’s references do. The engine registers a bound world as an
external scope: the values stay in your container, and no Patter save holds them. Leave World
out of Create and the engine self-backs @world from the declared defaults, as a property it
stores and saves with the rest of the run, which is right for a run that never leaves the engine;
GetBoundWorld() says which you have.
Two read-only rules meet here and stay distinct. A property declared writable: false in the
project is the story’s promise, refused by the engine whether or not a world is bound. Only
the story’s write is refused; your own SetProperty writes it, because the value is the game’s.
SetReadOnly is the game’s policy, a name the story may read but this game will not let it
write. Either refusal fails the step and logs why, never crashes, and neither binds your own Set*
calls. A bound container is never in a Patter save, so your game saves it once, however it already
saves things, and a load never writes through it. The binding survives HotSwap and ApplyLiveBundle.
It is the same shape as the Storylet Engine’s UStoryletEngine::Create(Bundle, …, World), so a
project running both reads one API. World Properties has the full picture.
One registry per game
Section titled “One registry per game”Every property value lives in a registry, a patter::ScopeRegistry (the plugin’s
Patter/Expr/ScopeRegistry.h, the same registry every Patterplay runtime uses). A game has one, and
it holds every property in the game, except values your game keeps itself and lends through a
container like UPatterWorld. The registry is saved and loaded as one.
An engine you build with Create makes its own and acts as its own game, which is why a single
UPatterSave::SerializeState needs no wiring. A C++ game that wants to hold the properties itself,
or share them with its own systems, makes the registry and hands it to the engine with
UPatterEngine::CreateWithRegistry:
#include "Patter/Save.h" // patter::saveRegistry / loadRegistry
auto Registry = std::make_shared<patter::ScopeRegistry>();patter::ScopeDeclaration Gold;Gold.name = "gold";Gold.type = "number";patter::OwnedScopeOptions Game;Game.owner = "Game";Registry->defineOwned("world", { Gold }, Game); // @world, stored and savedUPatterEngine* Engine = UPatterEngine::CreateWithRegistry(Bundle, Registry);
// One save for the game: the registry's values once, and the engine's part.const std::string RegistryJson = patter::saveRegistry(*Registry);const FString PatterJson = UPatterSave::SerializeState(Engine);
// Load in either order: values for bags that aren't open yet wait in the registry.patter::loadRegistry(*Registry, RegistryJson);UPatterSave::DeserializeState(Engine, PatterJson);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 a UPatterWorld passed as the third argument, 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 lists it in the bundle. Only the
other engine’s shared values are visible. If no engine on Patterplay’s registry registered that
scope, Patterplay refuses the content before anything changes: OpenFlow returns null and
UPatterSave::DeserializeState returns false, each logging this content names @story, which no engine on this registry registered: give every engine the game's one registry as an error. So build
every engine on the game’s one registry before opening a flow or loading a save. If another 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:
CreateWithRegistry returns null and logs an error that names who got there first, and your registry
is left as it was. Rebuilding an engine on an edited bundle (HotSwap, ApplyLiveBundle) hands its
bags to the replacement on the same registry.
CreateWithRegistry is C++ only: the registry is a standard C++ object shared by pointer, and no
Blueprint pin carries one. A Blueprint game uses Create, and UPatterSave saves everything. In the
core, the same option is patter::EngineOptions::registry.
With the Storylet Engine
Section titled “With the Storylet Engine”patter::ScopeRegistry is the shared expression kernel’s wildwinter::expr::ScopeRegistry, and the
Storylet Engine plugin carries the same kernel, byte for byte. In a game with both plugins,
patter::ScopeRegistry and storylets::ScopeRegistry are one type, so the registry above goes to
UStoryletEngine::CreateWithRegistry too, and each engine reads the other’s scopes through it.
- Build both plugins from the same kernel. Their headers are compiled into your game module, so if one plugin is older, the module that includes both stops at a compile error that says so. Update the older plugin.
- The module that makes the registry needs exceptions:
bEnableExceptions = truein itsBuild.cs, and#include "Patter/Kernel.h", since the registry refuses by throwing. A module that only callsUPatterEngineneeds neither. - Errors. The engine reports every refusal as
patter::EvalError, as it always has; a call you make on the registry yourself throws the kernel’swildwinter::expr::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. Both of
these are BlueprintCallable, so a designer can wire them without C++:
// Reuses the "guard-42" flow, so its shuffles and once-each lists keep their placeTArray<FPatterStep> Lines = Engine->RunFlow(TEXT("guard-42"), TEXT("npc-barks"), TEXT("greet"));
// Or move a flow you are already driving, exactly as an authored jump wouldbool bMoved = Flow->Goto(TEXT("throne-room"), TEXT("audience")); // false = cursor unmovedGive each independent speaker its own flow name. Full rules, and why OpenFlow behaves
differently: Host navigation. (In the underlying std C++ core the method
is gotoAddress, because goto is a reserved word; the Blueprint-facing name is Goto.)
Inspect live state
Section titled “Inspect live state”The plugin’s editor module adds a Window ▸ Tools ▸ Patterplay Runtime State panel. Register a
running engine and it lists that engine’s @patter properties live, with type-aware editors
(toggle / number / text / enum / flags) and a reset-to-default button, all writing straight back
into the playing game:
UPatterEngine* Engine = UPatterEngine::Create(Bundle);Engine->RegisterForDebug(TEXT("Main story")); // Play mode; the panel now watches this engine// It unregisters itself when destroyed. FPatterDebug::Register(Engine, Label) is the C++ equivalent.Values refresh a few times a second without clobbering a field you’re mid-edit, so you can poke a
number or flip a flag while you playtest. Every editor is also a plain Blueprint call
(ListProperties, GetProperty* / SetProperty*), so you can build the same watch-and-edit UI
into an in-game debug widget if you prefer. The panel lives in the plugin’s editor module, so it
never ships in a packaged game; the debug registry it reads is compiled out of Shipping builds too.
It’s the Unreal parity of Unity’s Runtime State window and Godot’s in-game inspector panel.
Follow the live cursor in Patterpad
Section titled “Follow the live cursor in Patterpad”FPatterDebugLink streams the running story position back to Patterpad so the editor follows the
cursor like a debugger. It compiles to no-ops in a Shipping build (the WebSockets dependency is
dropped there), so it is safe to leave wired in:
Link = FPatterDebugLink::Create(Engine->BuildId(), TEXT("My Game")); // Link: TSharedPtr<FPatterDebugLink>Link->FlowOpened(TEXT("main"));// ...after each Advance()/Choose() (map EPatterStepType -> "line"/"text"/"gameEvent"/"choice"/"end"):Link->Observe(TEXT("main"), Flow->CurrentScene(), Step.Id, StepTypeName(Step.Type));The protocol and the editor side are on Live refresh & debug.
Save and load
Section titled “Save and load”The runtime serialises the whole run: every flow’s position, visit counts, selector cursors, and the
seeded random generator. An engine you built with Create also carries every property value,
@patter, @scene, and a self-backed @world alike, so one call is still the whole game; an engine
you built on your own registry leaves them to the registry’s save. It is the
same patter/save@0 format every Patterplay runtime uses, so a save written on the web or in Unity
loads here. A save written before property values moved into the registry (version 2) still loads,
and its values move into the registry as it does.
Use UPatterSave, which is Blueprint-callable and gives you the JSON to write where you like:
SerializeState(Engine) returns it, DeserializeState(Engine, Json) restores it and returns
whether the file was accepted (a refusal is logged with its reason).
Prefer it over reaching past the wrapper. Loading REBUILDS the engine’s flows, so any UPatterFlow
you are holding refers to a flow that no longer exists; UPatterSave re-binds your wrappers to the
restored flows for you, and a flow the save did not carry comes back closed rather than dangling.
Calling loadGame() on the core engine through UPatterEngine::Raw() skips that step. Save/load & Game Data covers the format.
Build against the writer’s structure
Section titled “Build against the writer’s structure”UPatterEngine::GetOutline() and GetBeatSequence() expose the authored tree (scenes → blocks →
snippets → beats) as Blueprint structs, without playing. Walk the flat beat list and read each beat’s
GameData to build, say, a Sequencer of subsequences, one per beat. Structure introspection describes both calls.
- 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.