Scripting and commands
IScriptRuntime, RuntimeValue, and the token an awaited command settles — the seam between the story and your game code.
All documentation — Reference
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
On this page
IScriptRuntime is the seam between the story and everything it can touch: the playthrough’s
variables, the entity overlay, visit counts, commands and save state.
No entry point takes a source string. A script is named by its expression id, or by the entity and field that hold it. Expressions are parsed once at export time, so the plugin ships an evaluator and no parser.
Gates
| Member | Notes |
|---|---|
bool EvaluateCondition(string expressionId) | Never throws. A gate that cannot be evaluated is false, and is recorded for TakeFailures. |
bool EvaluateCondition(string expressionId, out string? failureReason) | The same, with why |
ExpressionEvaluation EvaluateExpression(string expressionId) | For a value rather than a yes or no |
IReadOnlyList<GateFailure> TakeFailures() | Every failure since the last call, which this call clears. Never null. |
A gate cannot have effects. A command reached in gate mode is refused rather than called — so a condition can be evaluated as often as the host likes, including for a preview.
An absent or blank expression passes. Whether a blank-but-present gate ought to fail is the caller’s decision, not the runtime’s.
EvaluateExpression is what a locked option needs to say reputation 2 / 5 rather than merely
naming the check. An implementation is allowed to always answer a failure, so write the fallback.
ExpressionEvaluation carries Result, Error and Ok — both halves, deliberately. A refusal
and an empty answer are two different facts, and returning nothing for an expression that could not
be evaluated would turn a broken gate into a branch that silently never happens.
GateFailure is ExpressionId, Source — the expression’s own text, so a log line can point at it —
and Error.
Running a script slot
ScriptExecutionResult ExecuteScript(string expressionId). Never throws out to the caller: a
failure comes back on the result, because the alternative is one bad expression ending a player’s
session.
| Member | Notes |
|---|---|
Success · Error | Error is present whenever Success is false |
InvokedCommands | What ran, in call order. Reported on the failing path too, for the ones that ran before the failure. |
AwaitedCommands | What was queued rather than run, in call order and not yet started |
InvokedCommand is a Code and its positional Args.
An entity’s script
A code field on an entity holds a script your game runs — a card’s On use, a trap’s On
trigger — and a condition field holds a check, such as whether the card can be played. The story
never runs them; you do, at the moment your game decides, and you supply what the script works on.
| Member | Notes |
|---|---|
ScriptExecutionResult ExecuteEntityScript(string entityRef, string fieldName, IReadOnlyDictionary<string, RuntimeValue>? bindings = null) | Runs the script in a code field. Never throws: a refusal comes back on the result, as for a script slot |
bool EvaluateEntityCondition(string entityRef, string fieldName, IReadOnlyDictionary<string, RuntimeValue>? bindings = null) | Checks the condition in a condition field, under a gate’s policy: a failure is false and is recorded for TakeFailures, and an empty field is true |
bool EvaluateEntityCondition(…, out string? failureReason) | The same, with why |
entityRef is the entity’s code or id — an id for an entity with no code, and for a
spawned instance. fieldName is the field’s name as a script writes it, a trait’s
under its prefix.
Inside the script, self is that entity. self.damage reads the entity’s own field and
self.damage += 1 writes it, so one script serves every entity that holds the same text — a
duplicated card needs no edit. A field may also declare roles: the other entities its script
receives, such as target and user. You pass one value per role in bindings, each an entity’s
code or id:
using System.Collections.Generic;using TaleShard.Runtime.Script;
namespace TaleShard.Docs.Samples{ public static class EntityScripts { // Plays a card: runs the script in its "onUse" field. Inside it, `self` is the card, and // the two roles the field declares are the entities passed here, each by code or id. public static bool PlayCard(IScriptRuntime runtime, string card, string target, string user) { if (!runtime.EvaluateEntityCondition(card, "usable", Roles(target), out string? why)) { System.Console.WriteLine("Cannot play " + card + ": " + (why ?? "its condition is false")); return false; }
ScriptExecutionResult result = runtime.ExecuteEntityScript(card, "onUse", new Dictionary<string, RuntimeValue> { ["target"] = RuntimeValue.Of(target), ["user"] = RuntimeValue.Of(user), }); // A refusal or a failed script comes back on the result, never as an exception. if (!result.Success) System.Console.WriteLine(card + " failed: " + result.Error); return result.Success; }
// Each copy the player owns is an instance: an id and values of its own, and part of the // save. Play it by passing its id where the card's code would go. public static string DealCopy(IScriptRuntime runtime, string cardCode) => runtime.SpawnEntity(cardCode);
private static Dictionary<string, RuntimeValue> Roles(string target) => new Dictionary<string, RuntimeValue> { ["target"] = RuntimeValue.Of(target) }; }}and the script reads and writes them like any entity: target.hp -= self.damage + user.strength.
The generated store offers the same call typed —
EntityStore.RunItemOnUse(item, target, user) — so a role
renamed in the editor fails your build at the named argument rather than your game at run time.
Refused before anything runs, each with a message naming what is wrong:
- an entity or a field that does not exist, or a field of the wrong type — a
codefield cannot be checked as a condition, nor aconditionfield run as a script; selfpassed inbindings— it is always the entity the script is on;- a binding the field does not declare, and a declared role left out;
- a role whose value is not the code or id of an entity;
- a role whose value is an entity of another kind — neither the definition the role names nor
one inheriting from it. A
Bossthat inheritsVillainis accepted whereVillainis asked for; aHerois not. An instance is its template’s kind. The check is the same through the typed store call and throughExecuteEntityScript; - a script that calls a command registered with
RegisterAsyncCommand, anywhere in it — on a branch the run would not take as much as on one it would. Only the story can wait for a command.
Inside the script, self and the roles cannot be reassigned, and a dotted name through any local
— a role, a loop variable, a let — reaches the entity the local names: for (c in party) c.hp += 1 endfor
writes each member. A local that names no entity reads as nothing, and a write through it is refused
rather than stored.
The resolved field tells you whether an entity holds a script at all: on a resolved entity’s code
or condition field, ResolvedField.ExpressionId is present exactly when the field holds one. Do not
hand that id to ExecuteScript — it would run with no self.
Instances
An entity is an authored row, and the overlay holds one set of values per row. A game where two copies of the same card change independently — one dagger sharpened, the other not — spawns instances:
| Member | Notes |
|---|---|
string SpawnEntity(string templateRef, string? instanceId = null) | A copy of an authored entity, by code or id. Returns its id: yours, or <templateId>#<n> from one counter. Throws ArgumentException for a template that is not an authored entity, an id already taken, or an id with a . in it (a path through the instance splits at its dots; a save holding one drops it on load) |
bool DespawnEntity(string instanceId) | Removes it and everything written to it. False for anything that is not a live instance |
IReadOnlyList<EntityInstance> GetInstances() | Every live instance, Id and TemplateId |
An instance reads its template’s authored values until something writes it, and its writes are its
own. It has no code, so a story’s text can never name it — a script reaches it only through a
binding: run the template’s script on it (ExecuteEntityScript(instanceId, "onUse", …), with
self bound to the instance), pass its id as a role, or hold it in a local. Its code field answers
its template’s code, which is how a script asks what kind of thing it is. rusty_dagger.damage in a
story still reads the authored row. The generated store has a typed spawn per definition,
SpawnItem(Item template).
Instances are part of the save, and Reset() removes them.
Reading and writing state
| Member | Notes |
|---|---|
RuntimeValue GetValue(string code) | By context-qualified code. An undeclared code reads as RuntimeValue.Null. |
void SetValue(string code, RuntimeValue value) | A code naming an entity field is routed to SetEntityFieldValue, so a write reaches the same store its read came from |
RuntimeValue GetEntityFieldValue(string entityRef, string fieldName) | The overlay first, then the authored value as its default. entityRef is a code or an id — an instance by its id. fieldName is any field the entity has, a trait’s under its prefix ("stats.armor") |
void SetEntityFieldValue(string entityRef, string fieldName, RuntimeValue value) | Writes into the playthrough overlay. A formula field (EngineEntityField.Computed) is refused with an ExpressionException and nothing is stored: its value is fixed for the playthrough |
IReadOnlyDictionary<string, RuntimeValue> GetAllVariables() | A live read-only view |
IReadOnlyDictionary<string, IReadOnlyDictionary<string, RuntimeValue>> GetEntityOverlay() | What this playthrough wrote, and never a copy of the authored data |
The project’s authored entities are read-only data and are never modified. A playthrough writes into an overlay beside them, which is why a save can be loaded into a project whose entities have since been re-authored.
Visits
| Member | Notes |
|---|---|
int GetVisitCount(string? nodeId) | What the language’s visit functions read. Pass null for the node the playthrough is standing on. |
void RecordVisit(string nodeId) | Called by the engine. A host does not normally call it. |
Commands
A command is how a story asks your game to do something. Wiring one is two lines and a choice of which kind.
| Member | |
|---|---|
void RegisterCommand(string code, CommandHandler handler) | Runs to completion during script execution |
void RegisterAsyncCommand(string code, AsyncCommandHandler handler, string? resultVariableCode = null) | The story suspends until the handler settles its token |
The two delegates:
CommandHandler | Takes the positional args, returns a RuntimeValue — RuntimeValue.Null when the command answers nothing |
AsyncCommandHandler | Takes the args and a CommandCompletion, returns nothing. It starts the work and gets out of the way. |
Registering the same code the other way replaces the registration rather than adding a second one.
resultVariableCode names the variable the engine writes the command’s answer into, or null to
discard it. A variable is the only durable form — saved, restored, rolled back, and the only
thing a gate can read. A command’s answer is never an expression value, because the evaluator cannot
suspend mid-expression.
CommandCompletion
The token an awaited handler settles when its work is done.
| Member | Notes |
|---|---|
Code · Args | As the author wrote them |
ResultVariableCode | Or null |
IsCompleted | |
IsStale | The story moved on without this — stopped, restarted or restored while the work was in flight |
Result · Failure | A failure never carries a result |
Complete() | Done, with nothing to say |
Complete(RuntimeValue) · Complete(string) · Complete(double) · Complete(bool) · Complete(IEnumerable<string>) | |
Fail(string reason) |
Check IsStale before touching the world. A completion that arrives after the playthrough was
torn down is dropped and reported at info level rather than as an error — the host did nothing wrong,
since it cannot un-fire a callback.
Three report codes worth recognising in a log: command-completed-off-thread,
command-completed-late, command-completed-twice.
Complete() on a command registered with a result variable does not write it, and warns — the
author asked for an answer and got none. Fail does not write it either, and invents no value,
so the author cannot mistake a failure for a real outcome. The story carries on in both cases.
RuntimeValue
Text, a number, a boolean, a flat list of scalars, or absence. Nothing else: there are no nested lists and no maps.
Numbers are always double, so the language has one number type and no integer division,
overflow or equality surprises.
RuntimeValueKind is Null · String · Number · Bool · List. Null is absence rather than
a value an author can write — the language has no null literal, and every coercion reads it as the
empty value of whatever type was asked for.
Making one
Every constructor is private.
| Factory | Notes |
|---|---|
RuntimeValue.Null | The shared absent value |
Of(string?) | A null string gives Null |
Of(double) · Of(bool) | |
OfList(IEnumerable<RuntimeValue>) | Throws on a nested list. A null member becomes Null. |
OfStrings(IEnumerable<string>) |
Immutable by construction: a list is copied on the way in and handed out as a read-only view, so a consumer cannot mutate a variable’s value behind the runtime’s back.
Reading one
Kind · IsNull · IsList, and four accessors — AsString(), AsNumber(), AsBool(),
AsList().
ToString() is a debugging rendering and not the language’s text coercion — the two differ.
Never put it in front of a player.
A RuntimeValue carries its own JSON converter, so a host’s plain serializer call round-trips it
without being handed settings. A PlaythroughSave does not — see saving.
RuntimeValues
The static that holds what a value means: the coercions, the comparisons and the set algebra. This is the language’s own behaviour, so reading a value through here is how your C# agrees with what the author wrote.
| Method | Notes |
|---|---|
ToBool | Absent is false, zero is false, empty text is false, and an empty list is false — so a gate naming a set alone asks whether it holds anything |
ToNumber | |
ToText | A list joins with a comma and a space, so it reads correctly mid-prose |
ToList | A scalar becomes a one-element list; absence becomes empty |
FormatNumber · FormatWhole | No exponent notation. Precision stops at six decimal places rather than claiming more than it has. |
ValuesEqual | Equality as the language means it, coercing across kinds. Also the membership test used by set algebra. |
CompareOrdered | For the ordering operators. Text compares ordinally, never by culture, so two words order the same way on every machine. |
SetUnion · SetDifference · SetIntersection | What + and - mean when a list is involved |
ContainsValue | A list needle asks whether every one of its members is present, not whether the list itself is a member |
IsNumericText · JsNumber · JsParseFloat |
SetUnion preserves insertion order and drops duplicates, which is what makes a save replay
identically rather than merely equivalently.
IsNumericText reads the whole string; JsParseFloat reads a numeric prefix and ignores the rest.
That difference is deliberate, and it is why text with a numeric prefix and a non-numeric tail does
not compare equal to its prefix while still reading as that number.
The runtime half of a save
| Member | Notes |
|---|---|
void Reset() | Every variable back to its authored default, the overlay and the instances cleared, and the generator re-seeded |
RuntimeStateSnapshot CaptureState() | |
void RestoreState(RuntimeStateSnapshot state) | Must not throw, including for a snapshot written against an older version of the project |
RuntimeStateLoadReport LastRestoreReport |
RuntimeStateSnapshot
| Member | Notes |
|---|---|
Variables | |
EntityOverlay | Keyed by entity id, each entry carrying the entity’s code alongside |
Instances | Every spawned instance by its id, a SavedInstance: the template’s TemplateId and, when it has one, its TemplateCode. Never null. Written as instances, and omitted when nothing was spawned, so such a save is unchanged |
VisitedCounts | |
RngState | The generator’s state. Required for a save to replay identically. |
CurrentNodeId | The node the playthrough stands on — what a visit count with no argument answers for. Null is legitimate. |
The overlay is keyed id-first with the code as a fallback because neither key alone is enough: an id keeps the state with the right entity when one was renamed and another took its code, and the code covers an entity deleted and recreated under a new id.
CurrentNodeId lives in the snapshot rather than at the save’s root so each rolled-back moment
evaluates its gates against its own node rather than against the live one.
An instance keeps its own id across a save, because its overlay entry is keyed by it, and finds its
template the same two ways: by id, then by code. Instances are restored before the overlay, so a
rollback to a moment before a spawn removes the instance, and the next minted id continues past the
highest restored #n.
SavedEntityOverlay is a Code and a dictionary of SavedOverlayField — a Value and, again, the
field’s code as the fallback key.
RuntimeStateLoadReport
DroppedEntities · DroppedVariables · DefaultedVariables · RefusedValues · IsClean.
The rules are deliberately not symmetric:
- an overlay entry whose entity was deleted is dropped, never resurrected;
- an instance whose template is gone under both keys, or whose id is now an authored entity’s id
or code, is dropped with what was written to it, and listed in
DroppedEntities; - a field added since the save reads its authored default, which falls out of the overlay simply having no entry for it;
- a variable the project no longer declares is dropped, since keeping it would leave a value nothing can read.
Every restore overwrites the report. Read it before restoring again — a silently defaulted variable is what makes “why is my gate false after loading?” unanswerable.
RefusedValues are values dropped for exceeding the size budget, rather than thrown on, because a
restore must not fail.