Unity-specific implementation of the GameScript V3 runtime.
The Unity runtime consumes FlatBuffers snapshots (.gsb) and executes dialogue with native C# conditions/actions. No build step required between authoring and play.
Key components:
- GameScriptLoader: Static entry point for loading manifests
- GameScriptManifest: Handle for querying locales and creating databases/runners
- GameScriptDatabase: Snapshot data access layer
- GameScriptRunner: Pure C# dialogue execution engine
- GameScriptBehaviour: Optional MonoBehaviour wrapper for Inspector integration
- RunnerContext: State machine for individual conversation execution
- Jump Tables: Array-based dispatch for conditions/actions
The runtime uses a Manifest-as-Handle pattern that eliminates partial states and double-loading:
// 1. Load the manifest (lightweight, contains locale list)
var manifest = await GameScriptLoader.LoadManifest();
// 2. Query available locales
LocaleRef locale = manifest.TryFindLocale(savedLocaleId, out var found)
? found
: manifest.PrimaryLocale;
// 3. Load the database for a specific locale
var database = await manifest.LoadDatabase(locale);
// 4. Create the runner (pure C# class, no partial state possible)
var runner = new GameScriptRunner(database, settings);Convenience methods combine steps when you don't need fine-grained control:
// Load manifest + database + create runner in one call
var runner = await manifest.CreateRunner(locale, settings);
// Or load everything with primary locale
var runner = await manifest.CreateRunner(settings);- No partial states: You cannot have a Runner without a Database, or a Database without a Manifest
- No double-loading: If you have a saved locale ID, load directly into it
- Locale picker support: Query
manifest.LocaleCountandmanifest.GetLocale(i)before committing to a locale - Testability: Pure C# classes can be unit tested without Unity
The manifest handles path construction and snapshot loading:
// Manifest caches paths on construction
var manifest = await GameScriptLoader.LoadManifest();
// Database loads the locale's snapshot
var database = await manifest.LoadDatabase(locale);FlatBuffers provides zero-copy access - data is read directly from the buffer.
GameScriptDatabase.EditorInstance provides lazy loading with staleness check for property drawers:
// Automatically checks manifest hash and reloads if changed
var database = GameScriptDatabase.EditorInstance;The manifest.json contains per-locale hashes updated on each export. This enables instant iteration - edit in GameScript, alt-tab to Unity, data is fresh.
// Database supports live locale switching
await database.ChangeLocale(newLocale);
// Subscribe to locale changes
database.OnLocaleChanged += () => RefreshUI();Developers write conditions and actions in their IDE with full tooling support:
public static class TavernConversation
{
[NodeCondition(456)] // nodeId
public static bool HasGold(IDialogueContext ctx)
=> GameState.PlayerGold >= 10;
[NodeAction(456)]
public static async Awaitable PayGold(IDialogueContext ctx, CancellationToken token)
{
GameState.PlayerGold -= 10;
await AnimationManager.Play("hand_over_gold", token);
// Can access node data if needed
ActorRef actor = ctx.Actor; // Who's speaking
}
}Conditions: Synchronous, return bool. Called during edge traversal.
Actions: Async via Awaitable, with CancellationToken for cooperative cancellation. Called when entering a node. Actions should check the token and exit early when cancelled.
Game-specific logic (inventory, animations, etc.) is accessed through your own systems. The IDialogueContext provides read-only access to the current node's data.
On runner creation, reflection scans for attributed methods and builds jump tables:
// Attributes specify nodeId
[NodeCondition(nodeId)]
[NodeAction(nodeId)]
// At construction:
// 1. Load snapshot, build ID-to-index map
// 2. Allocate arrays parallel to snapshot.Nodes
// 3. Scan assemblies for attributed methods
// 4. Place function pointers at their node's array index
// At runtime:
if (node.HasCondition)
bool result = conditions[nodeIndex](context);
if (node.HasAction)
await actions[nodeIndex](context);Why arrays, not dictionaries:
- O(1) lookup with no hashing overhead
- Cache-friendly
- Node IDs may be sparse, but memory overhead is minimal
Read-only access to the current node's data:
public interface IDialogueContext
{
// Cancellation token for cooperative cancellation
CancellationToken CancellationToken { get; }
// Current node data (from FlatBuffers snapshot)
int NodeId { get; }
int ConversationId { get; }
ActorRef Actor { get; } // Who's speaking
string VoiceText { get; } // Runner-resolved voice text (gender/plural/template applied)
string UIResponseText { get; } // Runner-resolved UI response text
int VoiceTextLocalizationIdx { get; } // Index into snapshot.Localizations (-1 if none)
int UIResponseTextLocalizationIdx { get; } // Index into snapshot.Localizations (-1 if none)
int PropertyCount { get; }
NodePropertyRef GetProperty(int index);
}The context provides node data and cancellation support - game-specific logic lives in your own code. The VoiceText and UIResponseText properties return fully resolved text (gender, plural, and template substitution have already been applied by the runner). The localization index properties allow direct access to the underlying localization entry if needed.
The RunnerContext implements a state machine for conversation flow:
ConversationEnter
↓ (await OnConversationEnter)
CacheNodeTexts
↓ (OnSpeechParams → resolve voice text)
↓ (OnDecisionParams per choice → resolve UI response texts)
NodeEnter
↓ (await OnNodeEnter)
ActionAndSpeech
↓ (Logic nodes: action only)
↓ (Dialogue nodes: action + OnSpeech(node, voiceText) concurrent)
↓ (await both complete)
EvaluateEdges
↓ (check conditions on outgoing edges)
├→ No valid edges? → ConversationExit
├→ Decision required? → await OnDecision
└→ Auto-advance? → OnAutoDecision (sync)
NodeExit
↓ (await OnNodeExit)
→ Loop back to NodeEnter
ConversationExit
↓ (await OnConversationExit)
Cleanup
↓ (await OnCleanup - always called in finally)
Idle (context returned to pool)
Outgoing edges are pre-sorted by priority in the snapshot. Traversal:
for (int i = 0; i < node.OutgoingEdgeIndicesLength; i++)
{
var edge = snapshot.Edges(node.OutgoingEdgeIndices(i));
var target = snapshot.Nodes(edge.TargetIdx);
// Check condition if present
if (!target.HasCondition || EvaluateCondition(target))
{
validTargets.Add(target);
}
}// Full control over initialization
var manifest = await GameScriptLoader.LoadManifest();
var locale = manifest.TryFindLocale(savedId, out var l) ? l : manifest.PrimaryLocale;
var database = await manifest.LoadDatabase(locale);
var runner = new GameScriptRunner(database, settings);
// Start conversations
ActiveConversation handle = runner.StartConversation(conversationId, listener);
// Manage conversations
bool isRunning = runner.IsActive(handle);
runner.StopConversation(handle);
runner.StopAllConversations();For scene-based setup with Inspector configuration:
// Add GameScriptBehaviour to a GameObject
// Configure settings and locale in Inspector
// It initializes automatically on Awake
// Access the runner after initialization
if (behaviour.IsInitialized)
{
behaviour.Runner.StartConversation(conversationId, listener);
}Implement to handle dialogue events. Async methods receive a CancellationToken for cooperative cancellation:
public interface IGameScriptListener
{
// Async lifecycle methods - return when ready to proceed
Awaitable OnConversationEnter(ConversationRef conv, CancellationToken token);
Awaitable OnNodeEnter(NodeRef node, CancellationToken token);
// Text resolution params - called before OnSpeech/OnDecision to get TextResolutionParams
// Default: auto-resolve gender, PluralCategory.Other, no template args
virtual TextResolutionParams OnSpeechParams(LocalizationRef localization, NodeRef node) => default;
virtual TextResolutionParams OnDecisionParams(LocalizationRef localization, NodeRef choiceNode) => default;
// Present dialogue - voiceText is fully resolved (gender/plural/template applied)
Awaitable OnSpeech(NodeRef node, string voiceText, CancellationToken token);
// Player choice - each ChoiceRef carries pre-resolved UIResponseText
Awaitable<ChoiceRef> OnDecision(IReadOnlyList<ChoiceRef> choices, CancellationToken token);
Awaitable OnNodeExit(NodeRef node, CancellationToken token);
Awaitable OnConversationExit(ConversationRef conv, CancellationToken token);
// Async cleanup methods - no cancellation token (must complete)
Awaitable OnConversationCancelled(ConversationRef conv);
Awaitable OnError(ConversationRef conv, Exception e);
Awaitable OnCleanup(ConversationRef conv);
// Sync auto-advance - returns a ChoiceRef
virtual ChoiceRef OnAutoDecision(IReadOnlyList<ChoiceRef> choices)
=> choices[Random.Range(0, choices.Count)];
}V3 Changes from V2:
OnSpeechnow receives astring voiceTextparameter (pre-resolved by the runner)OnDecisionnow takesIReadOnlyList<ChoiceRef>instead ofIReadOnlyList<NodeRef>, and returnsChoiceRef- New
OnSpeechParams/OnDecisionParamscallbacks for providingTextResolutionParams OnAutoDecisiontakes and returnsChoiceRefinstead ofNodeRef
public class MyDialogueUI : MonoBehaviour, IGameScriptListener
{
// Pooled completion source to avoid allocation per decision
AwaitableCompletionSource<ChoiceRef> _decisionSource = new();
// Provide text resolution params for templated speech
public TextResolutionParams OnSpeechParams(LocalizationRef localization, NodeRef node)
{
return new TextResolutionParams
{
Plural = new PluralArg("count", GameState.ItemCount),
Args = new[] { Arg.String("player", GameState.PlayerName) }
};
}
public async Awaitable OnSpeech(NodeRef node, string voiceText, CancellationToken token)
{
// voiceText is already fully resolved by the runner
dialogueText.text = voiceText;
await Awaitable.WaitForSecondsAsync(2f, token);
}
public async Awaitable<ChoiceRef> OnDecision(IReadOnlyList<ChoiceRef> choices, CancellationToken token)
{
// Early exit if already cancelled
if (token.IsCancellationRequested)
throw new OperationCanceledException(token);
foreach (ChoiceRef choice in choices)
{
// UIResponseText is pre-resolved by the runner
CreateButton(choice.UIResponseText, () => _decisionSource.TrySetResult(choice));
}
try
{
// OnConversationCancelled will call TrySetCanceled() if cancelled
return await _decisionSource.Awaitable;
}
finally
{
// Always reset, whether completed normally or cancelled
_decisionSource.Reset();
}
}
public async Awaitable OnConversationCancelled(ConversationRef conv)
{
// Unblock pending decision when cancelled
_decisionSource.TrySetCanceled();
// Now we can fade out UI with an animation!
await FadeOutUIAsync();
}
public Awaitable OnError(ConversationRef conv, Exception e)
{
Debug.LogException(e);
return AwaitableUtility.Completed();
}
public Awaitable OnCleanup(ConversationRef conv)
{
// Final cleanup - always called
return AwaitableUtility.Completed();
}
// For methods that don't need to wait, return AwaitableUtility.Completed()
public Awaitable OnNodeEnter(NodeRef node, CancellationToken token)
=> AwaitableUtility.Completed();
}For simple async operations (timers, animations), pass the token directly:
await Awaitable.WaitForSecondsAsync(2f, token); // Automatically cancelledFor completion source waits (decisions, custom UI), use the side-channel pattern:
- Just await your completion source - no token checking needed
OnConversationCancelledis called immediately whenCancel()is requested- Your handler can call
TrySetCanceled()on the completion source to unblock - Use
try/finallyto ensureReset()is always called
public async Awaitable<ChoiceRef> OnDecision(IReadOnlyList<ChoiceRef> choices, CancellationToken token)
{
// No need to check token or use token.Register()
// Just await the source
return await _decisionSource.Awaitable;
}
public Awaitable OnConversationCancelled(ConversationRef conv)
{
// Called immediately when Cancel() is requested
// Unblock the awaiting source
_decisionSource.TrySetCanceled();
return AwaitableUtility.Completed();
}Why this works: OnConversationCancelled is called immediately when StopConversation() is called, not when an exception is caught. This guarantees your completion sources are unblocked without needing token.Register() (which allocates).
The runner resolves all text before delivering it to listener callbacks. This ensures gender, plural, and template substitution are handled identically across all three runtimes.
- OnSpeechParams / OnDecisionParams — Listener returns
TextResolutionParams(gender override, plural arg, typed args). Default: auto-resolve everything. - Gender Resolution (
ResolveGender) — Priority:TextResolutionParams.GenderOverride> subject actor'sGrammaticalGender> localization'sSubjectGender>GenderCategory.Other. Dynamic actors without an override default to Other. - Plural Resolution (
CldrPluralRules) — If aPluralArgis provided, computes the CLDR plural category (Zero/One/Two/Few/Many/Other) using cardinal or ordinal rules. Supports decimal operands (i, v, w, f, t) viaPluralArg.Precisionfor correct handling of numbers like "1.0" vs "1". - Variant Selection (
VariantResolver.Resolve) — Three-pass fallback overLocalization.Variants: exact (plural+gender), gender fallback to Other, catch-all (Other/Other). - Template Substitution — If
Localization.IsTemplated, replaces{name}placeholders with formatted values fromPluralArgandArgs.
public struct TextResolutionParams
{
public GenderCategory? GenderOverride; // null = auto-resolve from snapshot
public PluralArg? Plural; // null = PluralCategory.Other
public Arg[] Args; // Named typed substitutions
}// Integer plural
new PluralArg("count", 5) // Cardinal, "5 items"
new PluralArg("place", 3, PluralType.Ordinal) // Ordinal, "3rd place"
// Decimal plural (Value / 10^Precision)
new PluralArg("weight", 15, 1) // 1.5 — uses CLDR operands i=1, v=1, w=1, f=5, t=5
new PluralArg("score", 100, 1) // 10.0 — uses CLDR operands i=10, v=1, w=0, f=0, t=0Arg.String("player", "Ada") // Plain string
Arg.Int("count", 1000) // "1,000" (locale-aware)
Arg.Decimal("rate", 314, 2) // "3.14" (locale-aware)
Arg.Percent("chance", 155, 1) // "15.5%" (locale-aware)
Arg.Currency("price", 1999, "USD") // "$19.99" (ISO 4217 decimal places)
Arg.RawInt("id", 42) // "42" (no formatting)public readonly struct LocalizationRef
{
public int SubjectActorIdx { get; } // -1 if no subject actor
public GenderCategory SubjectGender { get; }
public bool IsTemplated { get; }
public int VariantCount { get; }
// Static-gender-resolved text (no template substitution, dynamic actors → Other)
public string GetText();
}public readonly struct ChoiceRef
{
public int Index { get; }
public int Id { get; }
public ActorRef Actor { get; }
public string UIResponseText { get; } // Pre-resolved by runner
public NodeRef Node { get; } // Underlying node
// ... plus HasCondition, HasAction, IsPreventResponse, properties
}| File | Purpose |
|---|---|
TextResolutionParams.cs |
PluralArg, Arg, TextResolutionParams structs |
VariantResolver.cs |
Three-pass variant selection (plural × gender) |
CldrPluralRules.cs |
CLDR cardinal + ordinal rules with decimal operands |
Iso4217.cs |
Currency code → decimal places lookup |
Refs.cs |
LocalizationRef (SubjectActorIdx, GetText), ChoiceRef |
All editor UI built with UI Toolkit using pure C# (no UXML files). USS stylesheets are allowed for styling.
// Example picker structure
public class ConversationPickerWindow : EditorWindow
{
void CreateGUI()
{
var root = rootVisualElement;
root.styleSheets.Add(AssetDatabase.LoadAssetAtPath<StyleSheet>("...gamescript-picker.uss"));
var searchField = new ToolbarSearchField();
var tagFilters = new VisualElement { name = "tag-filters" };
var listView = new ListView();
root.Add(searchField);
root.Add(tagFilters);
root.Add(listView);
// ... bind data
}
}Entity references stored as ID wrapper structs with custom editor UI:
[SerializeField] ConversationId conversationId;
[SerializeField] LocalizationId localizationId;
[SerializeField] ActorId actorId;
[SerializeField] LocaleId localeId;
[SerializeField] NodeId nodeId;
[SerializeField] EdgeId edgeId;Drawer behavior:
- Loads current snapshot via
GameScriptDatabase.EditorInstance(with hot-reload check) - Displays searchable picker popup (UI Toolkit)
- Conversations/Localizations: Tag category filters + search
- Actors/Locales: Simple scrollable list
- Stores only the int ID wrapped in a type-safe struct
The property drawers read directly from the live snapshot. Workflow:
- Edit dialogue in GameScript
- Alt-tab away (triggers export)
- Return to Unity - property drawers show updated data
- Enter Play mode - runtime loads fresh snapshot
// Conversations
ConversationRef conv = database.FindConversation(conversationId);
string name = conv.Name;
NodeRef root = conv.RootNode;
// Nodes
NodeRef node = database.FindNode(nodeId);
int voiceLocIdx = node.VoiceTextLocalizationIdx; // Index into localizations
int uiLocIdx = node.UIResponseTextLocalizationIdx;
ActorRef actor = node.Actor;
// Localizations (V3 variant-based text)
LocalizationRef loc = database.FindLocalization(localizationId);
string text = loc.GetText(); // Static-gender-resolved, no template substitution
bool templated = loc.IsTemplated;
int subjectActorIdx = loc.SubjectActorIdx; // -1 if no subject actor
// Traverse edges
for (int i = 0; i < node.OutgoingEdgeCount; i++)
{
EdgeRef edge = node.GetOutgoingEdge(i);
NodeRef target = edge.Target;
}
// All entity types supported
ActorRef actor = database.FindActor(actorId);
LocalizationRef loc = database.FindLocalization(localizationId);
LocaleRef locale = database.FindLocale(localeId);
EdgeRef edge = database.FindEdge(edgeId);// Get category names
for (int i = 0; i < snapshot.ConversationTagNamesLength; i++)
{
string category = snapshot.ConversationTagNames(i);
// Build dropdown: "Act", "Location", "Quest"
}
// Get values for a category
var values = snapshot.ConversationTagValues(categoryIndex);
// Build dropdown: "All", "Act One", "Act Two", ...
// Filter conversations
bool Matches(Conversation conv, int[] selectedPerCategory)
{
for (int i = 0; i < selectedPerCategory.Length; i++)
{
if (selectedPerCategory[i] == -1) continue; // "All"
if (conv.TagIndices(i) != selectedPerCategory[i]) return false;
}
return true;
}Packages/studio.shortsleeve.gamescript/
Runtime/
GameScriptLoader.cs # Static entry point
Attributes.cs # NodeCondition, NodeAction attributes
Command.cs # IPC command structure for engine-to-editor communication
Manifest.cs # JSON manifest deserialization
Ids.cs # ConversationId, ActorId, NodeId, etc.
Refs.cs # NodeRef, ConversationRef, ActorRef, LocalizationRef, ChoiceRef, etc.
IDialogueContext.cs # Interface for conditions/actions
TextResolutionParams.cs # PluralArg, Arg, TextResolutionParams
VariantResolver.cs # Three-pass variant selection (plural × gender)
CldrPluralRules.cs # CLDR cardinal + ordinal rules with decimal operands
Iso4217.cs # Currency code → decimal places lookup
JumpTableBuilder.cs # Reflection-based function binding
Execution/
GameScriptManifest.cs # Manifest handle, creates databases/runners
GameScriptDatabase.cs # Snapshot data access
GameScriptRunner.cs # Pure C# dialogue execution
GameScriptBehaviour.cs # MonoBehaviour wrapper (optional)
RunnerContext.cs # Dialogue state machine
RunnerListener.cs # Listener interface
ActiveConversation.cs # Handle struct
Settings.cs # ScriptableObject settings
AwaitableUtility.cs # Completed awaitable helper
WhenAllAwaiter.cs # Zero-alloc concurrent task awaiter
Generated/
FlatSharp.generated.cs # Auto-generated FlatBuffers serialization
Editor/
GameScriptCommand.cs # Editor-side command handling
Build/
GameScriptBuildProcessor.cs
Menu/
Menus.cs
GameScriptSettingsProvider.cs
PropertyDrawers/
BaseIdDrawer.cs
ConversationIdDrawer.cs
LocalizationIdDrawer.cs
ActorIdDrawer.cs
LocaleIdDrawer.cs
Pickers/
BasePickerWindow.cs
BaseTwoLinePickerWindow.cs
BaseTaggedPickerWindow.cs
ConversationPickerWindow.cs
LocalizationPickerWindow.cs
ActorPickerWindow.cs
LocalePickerWindow.cs
Styles/
gamescript-picker.uss
- Object pooling: RunnerContext instances pooled and reused
- Jump tables: Array-based O(1) dispatch, no dictionary overhead
- Zero-copy data: FlatBuffers reads directly from buffer
- Lazy editor reload: Only check hash on data access, not every frame
- Main thread enforcement: All API calls validated for thread safety
- No partial states: Factory pattern ensures objects are fully initialized
- Zero-alloc async:
WhenAllAwaiteruses cached delegates and reference comparison instead of closures - Completed awaitable:
AwaitableUtility.Completed()returns a fresh pooled Awaitable (minimal allocation via Unity's internal pool)