Implementing commands
The generated hook a story calls, the starter file a sync writes, what happens if you never override it, argument coercion, and why not to hand-register.
All documentation — Playing a story
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
On this page
A command declared in the TaleShard editor becomes a virtual method on the generated
CommandHandlerBase. You override the ones your game answers, with public override, in a
partial class CommandHandler of your own; nothing else is required from you.
using System.Collections.Generic;using TaleShard.Docs.Fictions;
namespace TaleShard.Generated.Commands{ // Your half of the generated command handler. Put it anywhere in your project, in a file the // generator does not own. Nothing registers these: the generated bootstrapper does it before // anything can run a command. public partial class CommandHandler { // "playsound" — one field, sound_name (text). // The argument arrives coerced by the expression language's rules, not by a C# cast: // playsound(3) reaches here as "3" rather than throwing. public override void OnPlaysound(string soundName) { AudioManager.Play(soundName); }
// "spawn_enemy" — a number field declared decimal maps to float; declared whole, to int. public override void OnSpawnEnemy(string enemyCode, float x, float y, float z) { EnemyFactory.Spawn(enemyCode, x, y, z); }
// "grant_items" — a multi-valued asset field arrives as media ids. An omitted optional is // trimmed off the end rather than arriving as zero, so a count of 0 here was authored. public override void OnGrantItems(List<string> items, int count) { foreach (string mediaId in items) { Inventory.Grant(mediaId, count); } } }}Put that file anywhere in your project, in a file the generator does not own.
The starter file a sync writes for a new command
You do not have to type the override yourself. When a sync finds a command your game does not
override yet, it writes a starter file for it under Command Stubs, a path in the sync window’s
Advanced › Output Paths (default Scripts/TaleShardCommands, relative to Assets/).
- One file per command, named
CommandHandler.On<Command>.cs— declaremirrorbladeand you getCommandHandler.OnMirrorblade.cs. It holds apartial class CommandHandlerwith onepublic overrideof the hook, with the exact generated signature: the same parameter names, types, order and defaults. An awaited command gets the awaited form,public override void OnMirrorbladeAsync(…, CommandCompletion completion), under the same file name. - Its body calls the base —
base.OnMirrorblade(…), orbase.OnMirrorbladeAsync(…, completion). So until you replace that call with your own code, the command behaves exactly as if the file were not there: a plain command still reportscommand-not-implemented, and an awaited one still fails its wait. - Kept in step until you edit it, then yours. The sync remembers what it wrote to each file, as a
hash in its own state folder. While a file is still exactly that, a later sync keeps it matching
its command: it rewrites the file when the command’s parameters change, or when the story starts or
stops waiting for it (the hook becomes
OnMirrorbladeAsync, or goes back toOnMirrorblade, under the same file name), and deletes the file when the command is deleted. A line-ending conversion does not count as an edit. The first edit you make, of any kind, makes the file yours: from then on no sync rewrites, moves or deletes it. A file the sync did not write is never touched. The file carries no generated-code header and is never recorded as generated output. It opens with#nullable disable, so a plain hook’s= nulldefaults do not warn in a project that enables nullable reference types. Deleting a starter file you have not filled in brings it back on the next sync; to stop that, override the hook somewhere else or turn the starter files off. - Never written over an override you already have. Before writing, the sync asks the
CommandHandleryour project last compiled which hooks it declares, and skips every command whose hook you already override, in any file — so a project that already has its ownCommandHandler.Impl.csgets starter files only for the commands it has not answered. A command declared since your last compile is not compiled yet, so it gets its file. If the generated handler is in the project but no compiled one can be found (the project has not compiled since the code was generated, or the namespace changed), or while your scripts fail to compile (the compiled handler then predates your latest edits), the sync writes no new starter files that time and says so in the console; the next sync after a successful compile writes them. Files it wrote that you have not edited still follow their commands. - Renaming or deleting a command leaves a file you edited behind. The compiler then reports
that file’s
overrideas having no hook to override (CS0115), which is how you find the code to move or delete. A file you have not edited is deleted with its command, and a renamed command gets a new file under its new name.
The folder must be outside the Scripts and Media paths, which hold files the sync replaces
and deletes; the sync window refuses a path inside either. It must also compile into the same
assembly as the generated code, because a partial class cannot span two assemblies: if you put
the generated folder under an assembly definition, put this folder under the same one. Before
writing, the sync asks Unity which assembly each of the two folders compiles into; when they
differ it writes nothing and logs a warning naming both assemblies.
To turn the starter files off, clear the Command Stubs path. Files already written stay where they are, and no sync changes them while the path is empty. Pointing the path at another folder leaves the files in the old one as yours. Reset Sync State clears the sync’s memory of what it wrote, so every starter file already written is treated as yours from then on.
A command you have not overridden
Every hook is optional: the generated code compiles whether you override none of them or all of them, so a command a pack brings never breaks your build. And none is silent:
| The command is | Its default body | What you see |
|---|---|---|
| plain | reports that it is not implemented, once per command per session | a warning, code command-not-implemented, naming the method to override |
| awaited | fails the completion, naming the method to override | an error, code command-failed, and the story carries on instead of waiting for ever |
A plain command reports to CommandHandler.Diagnostics, which the generated bootstrapper sets to the
story’s diagnostics, and so to the Unity console; a handler you build yourself without setting it
reports to the console directly. Match on CommandHandlerBase.NotImplementedDiagnosticCode rather
than on the message.
What wires it up
| Mechanism | Who calls it |
|---|---|
the script runtime’s RegisterCommand(code, handler) | the engine, when a story runs a command — by code, with positional arguments |
the generated CommandHandler.Register(runtime, enums) | the generated bootstrapper, before anything can run a command. It registers one lambda per project command, and that lambda coerces each argument and calls the hook — your override, or the default |
So a story calling playsound("alert") reaches OnPlaysound("alert") with no wiring code from you.
Never hand-register a project command
You lose two things by doing it, and neither announces itself:
- The declared-type coercion. The generated lambda converts each argument to the parameter’s
declared C# type using the expression language’s own rules, so
playsound(3)arrives atOnPlaysound(string)as"3"— rather than a C# cast that would throw on the same call. - Optional-argument trimming. Omitted optional arguments are trimmed off the end. Without that,
an omitted optional arrives as
0,falseor"", and your hook cannot tell that apart from an author who passed one deliberately.
Registering your own command codes — ones no TaleShard command declares — is exactly what
RegisterCommand is for, and there is an example on
starting a session.
Parameter types
The parameter list comes from the command’s declared fields, in order, mapped through the same field-type table the rest of the generated code uses. Two mappings worth knowing now:
- A number field declared decimal maps to
float; the same field declared whole maps toint. - A field marked multiple maps to a
List<T>of what it would otherwise be. An asset field arrives as media ids, not as loaded assets — loading is yours, and it is deliberate, because when and whether to load is a game’s decision.
The verified half, and the unverified half
The sample above is compiled on every build against a declaration of the three hooks it overrides. That proves your half is legal C#. It does not prove the generator emits those exact signatures for those exact commands — the parameter names and types come from your project, through the field-type mapping.
The generator’s own output is held to compiling by a separate gate, which compiles what a real generator run emits from real fixtures. The two together are the guarantee; neither alone is.
A command a pack brings
A pack can bring a command along with its node types. The Puzzles & world pack brings retry(),
which a Code node calls when the story itself fails inside a checkpoint’s stretch. It becomes a hook
like any command of your own, and it is awaited — so the hook is the awaited form below,
public virtual void OnRetryAsync(CommandCompletion completion), with no parameters of its own. The
checkpoint handler overrides it in the same file.
Until you override it, the project still builds, and a story that calls it fails that wait with an
error naming OnRetryAsync and carries on.
Awaited is what keeps the story on the failure: it waits there instead of walking on into whatever comes after, an ending included. The hook only asks for a retry and keeps the token; the restore happens later, from your own game loop, and ends the wait by abandoning it — so that token is never completed. When there is no checkpoint to return to, the hook completes the token at once and the story carries on.
A command that takes time
A fade, a camera move, a video, a combat encounter — these cannot be a plain hook. The hook is called inside the advance, so returning from it lets the story carry on immediately, which is not what “wait for this” means. There is a separate completion protocol for those, and it has its own page in the Unity track.
Until that page exists, the short version: the awaited form of a hook is
public virtual void On<Command>Async(…, CommandCompletion completion), and it receives a completion
token; the story stays suspended until you complete or fail it. Its default body fails the token, so
a hook you have not overridden ends the wait with an error rather than a story that waits for ever.
The engine imposes no timeout — a command may legitimately last minutes, and resuming the story
behind the player’s back would be worse than waiting.
A command that takes frames runs as a coroutine, and a coroutine needs a MonoBehaviour. Run it on a
CommandCoroutineHost, a component that does nothing else. Unity logs an exception thrown inside
a coroutine and stops it, which answers nothing and would leave the story waiting for ever; the
host turns that exception into a failed command carrying its message. It does the same for a body
that finishes without answering, for every command still running when the host is destroyed, and
for a command handed to a host that is destroyed or disabled.
Stopping its coroutines is an abort, not a failure: the engine has already let go of a command whose
story stopped or restarted.
using System.Collections;using TaleShard.Runtime.Script;using TaleShard.Unity;
namespace TaleShard.Docs.Samples{ public static class AwaitedOnAHost { // Call this from your On...Async override. The host turns an exception, a body that // forgets to answer, and its own destruction into a failed command, so none of them // leaves the story waiting. public static void Begin(CommandCoroutineHost host, CommandCompletion completion, int frames) => host.Run(WaitThenComplete(completion, frames), completion);
private static IEnumerator WaitThenComplete(CommandCompletion completion, int frames) { for (int i = 0; i < frames; i++) yield return null; completion.Complete(); } }}