Skip to content

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.

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).

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.

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 option

The 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.

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 refused
UPatterEngine* 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.

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 saved
UPatterEngine* 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.

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 = true in its Build.cs, and #include "Patter/Kernel.h", since the registry refuses by throwing. A module that only calls UPatterEngine needs 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’s wildwinter::expr::RegistryError.

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 place
TArray<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 would
bool bMoved = Flow->Goto(TEXT("throne-room"), TEXT("audience")); // false = cursor unmoved

Give 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.)

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.

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.

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.

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.

Open source under the MIT licence, made by .