Skip to content

Scripting and commands

IScriptRuntime, RuntimeValue, and the token an awaited command settles — the seam between the story and your game code.

All documentation — Reference
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

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

MemberNotes
Success · ErrorError is present whenever Success is false
InvokedCommandsWhat ran, in call order. Reported on the failing path too, for the ones that ran before the failure.
AwaitedCommandsWhat 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.

MemberNotes
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:

EntityScripts.cs
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 code field cannot be checked as a condition, nor a condition field run as a script;
  • self passed in bindings — 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 Boss that inherits Villain is accepted where Villain is asked for; a Hero is not. An instance is its template’s kind. The check is the same through the typed store call and through ExecuteEntityScript;
  • 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:

MemberNotes
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

MemberNotes
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

MemberNotes
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:

CommandHandlerTakes the positional args, returns a RuntimeValue — RuntimeValue.Null when the command answers nothing
AsyncCommandHandlerTakes 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.

MemberNotes
Code · ArgsAs the author wrote them
ResultVariableCodeOr null
IsCompleted
IsStaleThe story moved on without this — stopped, restarted or restored while the work was in flight
Result · FailureA 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.

FactoryNotes
RuntimeValue.NullThe 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.

MethodNotes
ToBoolAbsent 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
ToTextA list joins with a comma and a space, so it reads correctly mid-prose
ToListA scalar becomes a one-element list; absence becomes empty
FormatNumber · FormatWholeNo exponent notation. Precision stops at six decimal places rather than claiming more than it has.
ValuesEqualEquality as the language means it, coercing across kinds. Also the membership test used by set algebra.
CompareOrderedFor the ordering operators. Text compares ordinally, never by culture, so two words order the same way on every machine.
SetUnion · SetDifference · SetIntersectionWhat + and - mean when a list is involved
ContainsValueA 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

MemberNotes
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

MemberNotes
Variables
EntityOverlayKeyed by entity id, each entry carrying the entity’s code alongside
InstancesEvery 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
RngStateThe generator’s state. Required for a save to replay identically.
CurrentNodeIdThe 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.

Written for
Unity developers · Advanced integrators
Last updated