Skip to content

One handler per node type

A node type's logic lives in its own file — a handler asset the player routes to, after the step event everything else listens to.

All documentation — Playing a story
On this page

A project’s node types are invented by the person writing the story, and a game usually wants to do something different for each of them: a dialogue node draws a line, a combat node starts a fight, a beat node only sets a variable. The obvious way to write that is one branch per type in one method, and it stops being readable at about the fifth type.

Instead, a node type’s logic lives in its own file. You write one TaleShardNodeHandler per type you care about, and the player sends each step to the one handler that claims its type.

The handler

Derive TaleShardNodeHandler, name the node type it answers for, and implement OnNodePlayed.

CombatNodeHandler.cs
using TaleShard.Docs.Fictions;
using TaleShard.Generated.Dialogues.NodeTypes;
using TaleShard.Runtime.Engine;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
[CreateAssetMenu(menuName = "TaleShard/Node Handlers/Combat")]
public sealed class CombatNodeHandler : TaleShardNodeHandler
{
// The generated constant, never a typed string: deleting the type in the editor breaks
// this line instead of matching nothing in a shipped build.
public override string NodeType => NodeTypes.Combat.TypeId;
public override void OnNodePlayed(TaleShardPlayer player, PlayStep step)
{
// Fields are read by CODE, on the step you were handed. There is no table of this
// type's field keys to consult, and none can be written: a field inside a repeater is
// keyed by the row instance, which exists only in one node's own data.
ResolvedEntity? enemy = step.Field(NodeTypes.Combat.Enemy)?.Entity();
if (enemy == null) return;
// Your code, not the plugin's.
EnemyFactory.Spawn(enemy.Code, 0f, 0f, 0f);
}
}
}

EnemyFactory there is not plugin API. It stands in for your own game code, and it is declared in a shim namespace on this site so that is visible rather than implied.

NodeType is a property rather than something you type into the Inspector, and that is the point: a node type’s stored id is custom:<uuid>, so the only readable way to name one is the generated NodeTypes.<Type>.TypeId constant — which stops compiling when the type is gone, instead of matching nothing in a shipped build. See the generated constants.

A handler is a ScriptableObject, so [CreateAssetMenu] puts it in the Assets menu and the asset can hold Inspector references — the prefab this type spawns, the clip it plays, the widget it opens.

Installing them

Create one asset per handler and drop them into the Node Handlers list on the TaleShard Player component. The order of that list means nothing: every handler names its own node type, so nothing about what your game does can change by dragging a row.

From your own bootstrap it is one call:

InstallNodeHandlers.cs
using TaleShard.Runtime.Engine;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
public sealed class InstallNodeHandlers : MonoBehaviour
{
[SerializeField] private TaleShardNodeHandler[] _handlers;
public void Begin(EngineSession story)
{
var player = new TaleShardPlayer(story);
// The global work first: this fires for every step, whether or not that step's type
// has a handler.
player.StepPresented += step => ClearAndDraw(step);
// Then the per-type work. Each handler names its own node type, so the order of this
// list decides nothing.
player.UseNodeHandlers(_handlers);
player.Play();
}
private void ClearAndDraw(PlayStep step)
{
}
}
}

Install them before the story starts. A handler installed after a step has been presented does not see that step — nothing is replayed.

The order, which is the contract

There is one event and a router in front of it, not two ways of hearing about a step.

  1. StepPresented fires, for every step, handler or not.
  2. Then the handler whose node type matches runs, if there is one.

So the split is: whatever is the same for every step goes on the event — clearing the panel, writing the transcript line, enabling the Continue button — and whatever belongs to one node type goes in that type’s handler, which can assume the event has already run.

There is deliberately no default handler. The event is the default.

What is refused, and what is not

Three things are refused when the list is installed, all for the same reason — the alternative would change what your game does with nothing to read:

Two handlers claiming one node typeRefused, naming both. Which one ran would otherwise be decided by the order of the list.
A handler naming no node typeRefused, naming the class. Nothing could ever reach it.
An empty row in the listRefused, naming its position. An empty row is a handler somebody believes is installed.

A node type with no handler is not an error. The step already reached StepPresented, so nothing was lost. The runtime notes it once per distinct node type in the diagnostics under the code node-handler-missing — once per type, not once per step, because a note on every step is a synonym for silence.

What a handler must never do

A handler is handed a resolved step. It reads fields by code with step.Field(...) and repeater rows with step.Blocks(...).

It must never ask what fields its node type has. That table cannot be written by anyone: a field inside a repeater is keyed by the repeater, the row instance and the field, and the row instance exists only inside one node’s own data — so a type’s field keys are a property of each node, not of the type. Field codes are a property of the type, they are generated, and they are what a handler uses. The reasoning is node meaning is resolved once.

A node that carries several lines

The Exchange, a premade type a project adds from TaleShard’s Library, is a run of lines between several characters that plays as one moment: each row of its lines repeater carries a speaker, the line, a pause in seconds to wait after it, and an optional beat note; the player’s replies are the rows of its replies repeater, one exit each, and they arrive as the step’s choices — never as lines. Its handler reads the rows in order and hands them to your own presenter, which shows each line, waits its pause, and only then offers the step’s choices — the order the editor’s own player keeps.

Which field is the pause is a fact the editor decides, not the handler: the block’s first number field, whether or not the author hides it on the card. It reaches you as a generated constant beside the repeater’s RepeaterCode — PauseField, holding that field’s code — so the handler matches it against field.Code like any other constant and paces by exactly the field the editor’s own player paces by. A repeater that is not drawn as a conversation has no PauseField.

ExchangeNodeHandler.cs
using System.Collections.Generic;
using TaleShard.Docs.Fictions;
using TaleShard.Generated.Dialogues.NodeTypes;
using TaleShard.Runtime.Engine;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
[CreateAssetMenu(menuName = "TaleShard/Node Handlers/Exchange")]
public sealed class ExchangeNodeHandler : TaleShardNodeHandler
{
public override string NodeType => NodeTypes.Exchange.TypeId;
public override void OnNodePlayed(TaleShardPlayer player, PlayStep step)
{
// A node that carries several lines: its repeater's rows, in the author's order.
// Each row is a list of resolved fields read by code, like the node's own; the
// repeater is named by its generated constant, and so is each field of a row. The
// pause is the one field the editor's own player paces by, and the generated
// `PauseField` constant names it, so the handler never spells its code.
var lines = new List<SpokenLine>();
foreach (IReadOnlyList<ResolvedField> row in step.Blocks(NodeTypes.Exchange.Lines.RepeaterCode))
{
ResolvedEntity? speaker = null;
string text = null;
double pauseAfter = 0;
foreach (ResolvedField field in row)
{
switch (field.Code)
{
case NodeTypes.Exchange.Lines.Speaker: speaker = field.Entity(); break;
case NodeTypes.Exchange.Lines.Line: text = field.Text(); break;
case NodeTypes.Exchange.Lines.PauseField: pauseAfter = field.Number() ?? 0; break;
}
}
// A line with no speaker is the narrator's. A row whose line is empty is still a
// beat: the editor's own player shows its bubble empty and waits its pause. The
// pause is seconds to wait AFTER this line before the next one; an unfilled pause
// is no wait at all.
lines.Add(new SpokenLine(speaker == null ? null : speaker.Name, text ?? "", (float)pauseAfter));
}
// Your code, not the plugin's: show each line, wait its pause, and only then offer
// the step's choices, which is the order the editor's own player keeps.
ConversationPresenter.Play(lines, step.Choices);
}
}
}

SpokenLine and ConversationPresenter are your code, not plugin API — the presenter is where the pacing lives, as a coroutine that yields each line’s pause. The handler’s job is the plugin’s half: the rows come from step.Blocks(...) in the author’s order, every field of a row is named by its generated constant — the pause by its role constant, PauseField, which is one field read by code on every side — a row whose speaker is empty is the narrator’s, and a row whose line is empty is still a beat: the editor’s own player shows its bubble empty and waits its pause, so the sample presents it too.

A row can hold a list of its own: a repeater declared inside another repeater’s block, one level. Its constants class nests inside the outer repeater’s — NodeTypes.Exchange.Lines.Asides — and its rows belong to one outer row. Read them with the outer row’s instance id, step.Blocks(NodeTypes.Exchange.Lines.Asides.RepeaterCode, row[0].Repeater?.InstanceId); the code alone answers every outer row’s inner rows, outer row by outer row, each row’s Repeater.Parent naming the outer row it belongs to. An inner row is content, never an exit.

A node whose picture carries places

A Location is a picture with places on it: a spots repeater whose rows each carry a name and a position — a point on the picture — and a zones repeater whose rows carry a name and an area — a rectangle on it. Both arrive in the picture’s own space: Point() answers X and Y as fractions 0..1 of the picture’s box from its top-left corner, and Rect() answers the corner and the size the same way. The plugin never sees pixels; multiply by the drawn picture’s size. Which picture a point belongs to is a fact the author chose in the editor, and it reaches you as a second generated constant beside the field’s own, suffixed Picture, holding the picture field’s code.

LocationNodeHandler.cs
using System.Collections.Generic;
using TaleShard.Docs.Fictions;
using TaleShard.Generated.Dialogues.NodeTypes;
using TaleShard.Runtime.Engine;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
[CreateAssetMenu(menuName = "TaleShard/Node Handlers/Location")]
public sealed class LocationNodeHandler : TaleShardNodeHandler
{
public override string NodeType => NodeTypes.Location.TypeId;
public override void OnNodePlayed(TaleShardPlayer player, PlayStep step)
{
// The picture the places are drawn on. Each position or area names the picture field
// it belongs to through a second generated constant beside its own, so the handler
// never spells the picture's code.
MediaRef? picture = step.Field(NodeTypes.Location.Spots.WherePicture)?.Media();
if (picture == null) return;
// A spot is a named point on the picture: `Point()` answers X and Y, each 0..1 of the
// picture's box from its top-left corner. Multiply by the drawn picture's size to
// place it; the plugin never sees pixels.
var spawnPoints = new List<SpawnPoint>();
foreach (IReadOnlyList<ResolvedField> row in step.Blocks(NodeTypes.Location.Spots.RepeaterCode))
{
string name = null;
Point2? where = null;
foreach (ResolvedField field in row)
{
switch (field.Code)
{
case NodeTypes.Location.Spots.Name: name = field.Text(); break;
case NodeTypes.Location.Spots.Where: where = field.Point(); break;
}
}
if (where == null) continue;
spawnPoints.Add(new SpawnPoint(name, (float)where.Value.X, (float)where.Value.Y));
}
// A zone is a named rectangle: `Rect()` answers its top-left corner and its size, in
// the same 0..1 space.
var triggerBoxes = new List<TriggerBox>();
foreach (IReadOnlyList<ResolvedField> row in step.Blocks(NodeTypes.Location.Zones.RepeaterCode))
{
string name = null;
Rect2? bounds = null;
foreach (ResolvedField field in row)
{
switch (field.Code)
{
case NodeTypes.Location.Zones.Name: name = field.Text(); break;
case NodeTypes.Location.Zones.Bounds: bounds = field.Rect(); break;
}
}
if (bounds == null) continue;
triggerBoxes.Add(new TriggerBox(
name, (float)bounds.Value.X, (float)bounds.Value.Y, (float)bounds.Value.W, (float)bounds.Value.H));
}
// Your code, not the plugin's: draw the picture, place the points and boxes on it, and
// hand back the door the player walks through. The doors are the step's choices, so
// taking one is an ordinary `Choose`.
LevelMapPresenter.Show(picture.Value, spawnPoints, triggerBoxes, step.Choices, choice => player.Choose(choice));
}
}
}

SpawnPoint, TriggerBox and LevelMapPresenter are your code, not plugin API. What a game does with a position is the handler’s business — a 2D level’s map is the obvious use, with the spots as spawn points and the zones as trigger boxes. The doors are the step’s choices, so the door the player walks through is taken with an ordinary player.Choose(choice).

What an outcome hands over

A Loot roll is a node whose outcome rows each carry items — entity items with a count each, the itemCounts field type — beside the row’s weight and its changes. One outcome is drawn when the player leaves the node, and that outcome’s items are handed over as the exit fires: they arrive on the step after the roll, as step.Trace.Received, exactly the way the exit’s own scripts arrive on that step’s ScriptsRun. Each entry is an ItemCount: the item, resolved like any entity reference when that step is composed — after the outcome’s own scripts have run, so a changes script that writes the item’s field is seen on it — its count, and its Slot — the cell of the inventory grid the author placed it in, or null when she placed it nowhere. The plugin keeps no inventory and merges nothing — an author who wrote Gel Pack ×2 and Gel Pack ×1 on one row hands over two entries, and a game that wants one stack sums them itself. A game with a grid inventory seats an entry at its cell; a game without one ignores the number.

This is not a per-type handler’s job, and the sample is deliberately not one. The step that carries the haul is whatever node the drawn outcome’s wire leads to, of any type, so the read belongs with the global work that runs for every step:

ReceivedItemsListener.cs
using TaleShard.Docs.Fictions;
using TaleShard.Runtime.Engine;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
public sealed class ReceivedItemsListener : MonoBehaviour
{
public void Begin(EngineSession story)
{
var player = new TaleShardPlayer(story);
// What an outcome handed over rides on the step AFTER the roll, whatever that
// step's type is, so the read belongs with the global work: every step, before any
// per-type handler.
player.StepPresented += step => Receive(step);
player.Play();
}
private static void Receive(PlayStep step)
{
// Empty on most steps. Each entry is the item, resolved like any entity, and a
// count; a repeated item arrives as repeated entries, in the author's order, and
// the plugin has summed nothing.
foreach (ItemCount entry in step.Trace.Received)
{
// Your code, not the plugin's: the plugin keeps no inventory.
Inventory.Receive(entry.Item.Code, entry.Count);
Debug.Log("Received " + entry.Item.Name + " x" + entry.Count);
// The cell the author seated it in, when the entry has one. A game without a
// grid inventory can ignore it.
if (entry.Slot.HasValue) Debug.Log(" in slot " + entry.Slot.Value);
}
}
}
}

Inventory is your code, not plugin API. The same field type on an entity definition — a recipe’s ingredients, or a chest’s contents — is an ordinary field: field.Items() answers the same ItemCount list, the entities zipped with their counts in the author’s order, and with their cells when the field is a grid: a field with a capacity is drawn as that many cells in the editor, and each entry’s Slot is the cell it is shown in there, so a chest lays out in the game exactly as the author laid it out — field.Capacity cells, field.Columns wide, both read off the resolved field rather than the type declaration. A language change redraws a step with the same entries; a restored save lists none, as it lists no script runs. And the field’s entities are never on a step’s Cast: they are the things a row hands over, not who is on stage, so a node whose only entity references are its items has an empty cast.

A node that places rows in time

A Sequence is a track: lanes of rows on one time ruler. Each lane is an ordinary repeater whose block marks one number field as a row’s start and, when the rows are spans rather than instants, one as its duration — two flags the author sets in the editor, in whatever unit the type’s numbers use. A type may also name one node-level number field as the track’s length, the ruler’s end. All three reach you as generated constants beside the ones you already know: StartField and DurationField next to a lane’s RepeaterCode, TrackLengthField next to the type’s TypeCode. Each holds the field’s code, so a handler matches it against field.Code inside a row exactly as it matches any other constant — and a lane whose rows are instants has no DurationField at all.

A sequence is a presented node: its scripts have run and every command they awaited has settled before the handler sees the step, so the rows are final. The handler reads every lane, schedules the rows on its own clock, waits for the length — or for the last row to end, when the type names no length or the author left it blank or at zero — and then leaves. A row whose start is blank sits at zero, where the editor’s card draws it and its own player plays it.

SequenceNodeHandler.cs
using System;
using System.Collections.Generic;
using TaleShard.Docs.Fictions;
using TaleShard.Generated.Dialogues.NodeTypes;
using TaleShard.Runtime.Engine;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
[CreateAssetMenu(menuName = "TaleShard/Node Handlers/Sequence")]
public sealed class SequenceNodeHandler : TaleShardNodeHandler
{
public override string NodeType => NodeTypes.Sequence.TypeId;
public override void OnNodePlayed(TaleShardPlayer player, PlayStep step)
{
// A node that places its rows in time. By the time it reaches a handler its scripts
// have run and every command they awaited has settled, so the rows are final. Each
// lane is a repeater whose block marks one number field as a row's START and, when
// the rows are spans, one as its DURATION; the generated `StartField` and
// `DurationField` constants name them, so the handler never spells a code.
var timeline = new Timeline();
double lastEnd = 0;
// A lane of spans: each row starts at `StartField` and lasts `DurationField`.
foreach (IReadOnlyList<ResolvedField> row in step.Blocks(NodeTypes.Sequence.Cues.RepeaterCode))
{
// A row with no start sits at the ruler's zero, where the editor's card and its
// own player place it; an unfilled duration is an instant.
string cue = null;
double at = 0;
double duration = 0;
foreach (ResolvedField field in row)
{
switch (field.Code)
{
case NodeTypes.Sequence.Cues.Cue: cue = field.Text(); break;
case NodeTypes.Sequence.Cues.StartField: at = field.Number() ?? 0; break;
case NodeTypes.Sequence.Cues.DurationField: duration = field.Number() ?? 0; break;
}
}
timeline.Add(NodeTypes.Sequence.Cues.RepeaterCode, cue, (float)at, (float)duration);
lastEnd = Math.Max(lastEnd, at + duration);
}
// A lane of instants: the block marks a start and no duration, so there is no
// `DurationField` constant to read, and every row is a mark at one time.
foreach (IReadOnlyList<ResolvedField> row in step.Blocks(NodeTypes.Sequence.Marks.RepeaterCode))
{
string cue = null;
double at = 0;
foreach (ResolvedField field in row)
{
switch (field.Code)
{
case NodeTypes.Sequence.Marks.Cue: cue = field.Text(); break;
case NodeTypes.Sequence.Marks.StartField: at = field.Number() ?? 0; break;
}
}
timeline.Add(NodeTypes.Sequence.Marks.RepeaterCode, cue, (float)at, 0f);
lastEnd = Math.Max(lastEnd, at);
}
// The ruler's end: the type's own length field when the author filled it with a
// number above zero, else the end of the last row. A blank or a zero is an unfilled
// length. The handler waits for the end, then leaves.
double? declared = step.Field(NodeTypes.Sequence.TrackLengthField)?.Number();
double length = declared > 0 ? declared.Value : lastEnd;
// Your code, not the plugin's: a clock that fires each entry at its start, holds it
// for its duration, and moves the story on when it reaches the end. A game that ends
// the sequence early takes one of the step's choices instead, exactly as it would on
// any other node.
timeline.Play((float)length, () =>
{
// Only for THIS step: a story that has already moved on, by a choice taken early
// or a save restored, is not advanced by a clock it left behind.
if (player.CurrentStep?.Id != step.Id) return;
// A sequence with one way out advances. One whose ends are labelled offers them
// as the step's choices and is waiting for one, so Advance is ignored: the clock
// takes the end the game reserves for running out, here the last one offered.
if (step.Choices.Count == 0) player.Advance();
else player.Choose(step.Choices[step.Choices.Count - 1]);
});
}
}
}

Timeline is your code, not plugin API — the clock lives there, as a coroutine that fires each entry at its start and holds it for its duration, then calls back. What the callback does is the part worth copying: it checks the step on screen is still the one it was armed for, then advances a sequence with one way out and chooses for one whose ends are labelled — labelled ends are the step’s choices, the player is awaiting one, and Advance is ignored while it is. Which end the clock takes is your rule; the sample takes the last one offered. The handler’s job is the plugin’s half: which field of a row is its start is a fact the author chose, it arrives as a constant, and a rename in the editor breaks the build rather than placing every row at zero. Two constants of one lane can hold the same code — Cues.At and Cues.StartField both say "at" — so read a row through the role constant or the field constant, never both in one switch.

A node that teaches a move

The Puzzles & world pack’s Teach stops the story until the player has done a move. It names the move as one or more Input action records — never a key — and its prompt is rich text in which a mention of an action draws as a keycap. Done when says what counts: pressed (several actions are one chord), held for so many seconds, repeated so many times, moved — a 2D move used for so many seconds — the actions in sequence, dismissed for a pop-up the player closes, or dontWait for a tip that stays up a few seconds while the story flows on. Nudges fire in the order of their delay if the player has not done it yet: a pulse of the prompt, or a line with a speaker. A boolean variable remembers it, and a player who already knows the move is not taught it again.

TaleShard owns the words, the rule and the nudge order. The binding, the listening, the art and the clock are your game’s, and the handler reaches them through a small interface you implement — so nothing in it references an input package.

TeachNodeHandler.cs
using System.Collections.Generic;
using TaleShard.Generated.Dialogues.NodeTypes;
using TaleShard.Runtime.Engine;
using TaleShard.Runtime.Script;
using TaleShard.Runtime.Text;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
// Your game's half. The binding, the listening, the art and the clock are the game's. An
// action is named by the CODE of its Input action record ("Crouch"); map it to your own input
// action in your implementation. Nothing here references an input package.
public interface ITeachGame
{
// The action is held down right now.
bool IsDown(string actionCode);
// The action went down this frame.
bool WasPressed(string actionCode);
// How far a 2D action is pushed this frame, from 0 to 1.
float MoveAmount(string actionCode);
// Markup for the player's CURRENT binding of the action, such as a TextMeshPro sprite
// tag, or null to draw the action's name. Asked every time the prompt is drawn.
string GlyphFor(string actionCode);
void ShowPrompt(string title, string markup);
void PulsePrompt();
void Say(ResolvedEntity speaker, string line);
void HidePrompt();
}
[CreateAssetMenu(menuName = "TaleShard/Node Handlers/Teach")]
public sealed class TeachNodeHandler : TaleShardNodeHandler
{
// The option values of the type's "done when" select.
private static class Rule
{
public const string Pressed = "pressed";
public const string Held = "held";
public const string Repeated = "repeated";
public const string Moved = "moved";
public const string Sequence = "sequence";
public const string Dismissed = "dismissed";
public const string DontWait = "dontWait";
}
// How far a 2D action must be pushed to count as moving.
private const float MoveThreshold = 0.5f;
// Your game, handed over before the story starts: an asset cannot hold a scene object.
public ITeachGame Game { get; set; }
// One player's lesson: give each player that runs at the same time its own asset.
private TaleShardPlayer _player;
private PlayStep _step;
private EntityPalette _palette;
private readonly List<string> _actions = new List<string>();
private readonly List<Nudge> _nudges = new List<Nudge>();
private string _rule;
private double _needed;
private string _remembersIn;
private double _progress;
private float _waited;
private int _nextNudge;
private float? _tipLeft;
private bool _showing;
private bool _waiting;
private bool _leaving;
private bool _remember;
public override string NodeType => NodeTypes.Teach.TypeId;
public override void OnNodePlayed(TaleShardPlayer player, PlayStep step)
{
// The step of the lesson still running, again, after a language change or a redraw:
// draw its prompt anew and carry on with the lesson as it was.
if (_step != null && step.Id == _step.Id && (_waiting || _showing))
{
_step = step;
RedrawPrompt();
return;
}
// A new lesson replaces whatever the last one left on screen, a tip included. One still
// owed its move-on has been moved past already, so only its remembering is left.
if (_showing) Hide();
if (_leaving && _remember && _remembersIn != null)
{
player.Story.Runtime.SetValue(_remembersIn, RuntimeValue.Of(true));
}
_leaving = false;
_player = player;
_step = step;
_palette = new EntityPalette(player.Story.View);
_actions.Clear();
ResolvedField? actions = step.Field(NodeTypes.Teach.Action);
if (actions != null)
{
foreach (ResolvedEntity action in actions.Entities()) _actions.Add(action.Code);
}
_rule = step.Field(NodeTypes.Teach.DoneWhen)?.Option() ?? Rule.Pressed;
_needed = NumberFor(step, _rule);
_remembersIn = step.Field(NodeTypes.Teach.RemembersIn)?.Variable()?.Name;
ReadNudges(step);
_progress = 0;
_waited = 0;
_nextNudge = 0;
_tipLeft = null;
_waiting = false;
_remember = false;
// Already learned: no prompt at all, and the story moves on at the next Tick.
if (_remembersIn != null && RuntimeValues.ToBool(player.Story.Runtime.GetValue(_remembersIn)))
{
_leaving = true;
return;
}
if (_rule == Rule.DontWait)
{
// A tip: the story flows on at once, and the tip stays up on the game's clock.
// A tip with no time stays until the next prompt replaces it.
if (_needed > 0) _tipLeft = (float)_needed;
_leaving = true;
_remember = true;
}
else
{
_waiting = true;
}
_showing = true;
RedrawPrompt();
}
// Call once a frame from your own game loop, with the frame's length in seconds.
public void Tick(float deltaSeconds)
{
if (_player == null) return;
if (_tipLeft != null)
{
_tipLeft -= deltaSeconds;
if (_tipLeft <= 0f) Hide();
}
if (_leaving)
{
Leave();
return;
}
if (!_waiting) return;
// The story moved on some other way, by a choice or a restore: the lesson is over.
if (_player.CurrentStep?.Id != _step.Id)
{
_waiting = false;
Hide();
return;
}
_waited += deltaSeconds;
RunNudges();
if (IsDone(deltaSeconds)) Finish();
}
// Your pop-up was closed. A lesson done when "dismissed" is done.
public void Dismiss()
{
if (_waiting && _rule == Rule.Dismissed) Finish();
}
// Draw the prompt again. Call it when your game's controls change (a rebind, another
// device) so every action shows the binding the player has now.
public void RedrawPrompt()
{
if (!_showing || Game == null) return;
string title = _step.Field(NodeTypes.Teach.Name)?.Text();
string markup = FieldMarkup.Of(_step.Field(NodeTypes.Teach.Prompt), null, Mention);
Game.ShowPrompt(title, markup);
}
// The mention hook: the palette's look, with the player's current binding for an action.
// Hand the same hook to every formatter, so an action mentioned in any line draws the same.
private MentionLook? Mention(string entityId)
{
MentionLook? look = _palette.ResolveMention(entityId);
if (look == null || !look.Value.IsAction) return look;
return look.Value.WithGlyph(Game.GlyphFor(look.Value.ActionCode));
}
private bool IsDone(float deltaSeconds)
{
// A lesson that names no action has nothing to listen for, and waits.
if (_actions.Count == 0) return false;
switch (_rule)
{
case Rule.Held:
// Held that long without letting go; several actions are held together.
if (!AllDown())
{
_progress = 0;
return false;
}
_progress += deltaSeconds;
return _progress >= _needed;
case Rule.Moved:
// A 2D move used that long in all, pauses allowed.
if (!AnyMoving()) return false;
_progress += deltaSeconds;
return _progress >= _needed;
case Rule.Repeated:
if (!ChordPressed()) return false;
_progress += 1;
return _progress >= _needed;
case Rule.Sequence:
// The actions in the list's order; a listed action out of turn starts again.
int next = (int)_progress;
if (Game.WasPressed(_actions[next])) _progress = next + 1;
else if (AnyPressed()) _progress = Game.WasPressed(_actions[0]) ? 1 : 0;
return _progress >= _actions.Count;
case Rule.Dismissed:
return false;
default:
// Pressed once; several actions are one chord, pressed together.
return ChordPressed();
}
}
private void Finish()
{
_waiting = false;
Hide();
_leaving = true;
_remember = true;
}
private void Hide()
{
_showing = false;
_tipLeft = null;
Game?.HidePrompt();
}
private void Leave()
{
_leaving = false;
// Only from the teach step itself; a story that has moved on is not moved again.
// Advance leaves by the first way out whose condition holds, in the order the type
// lists them: "knows it" first, taken when the variable holds, then "done".
if (_player.CurrentStep?.Id == _step.Id) _player.Advance();
// Written AFTER moving on: "knows it" is gated on this variable, so writing it first
// could send a lesson just finished out by "knows it".
if (_remember && _remembersIn != null)
{
_player.Story.Runtime.SetValue(_remembersIn, RuntimeValue.Of(true));
}
_remember = false;
}
private void RunNudges()
{
while (_nextNudge < _nudges.Count && _waited >= _nudges[_nextNudge].After)
{
Nudge nudge = _nudges[_nextNudge++];
if (nudge.Line != null) Game?.Say(nudge.Speaker, nudge.Line);
else Game?.PulsePrompt();
}
}
private void ReadNudges(PlayStep step)
{
_nudges.Clear();
foreach (IReadOnlyList<ResolvedField> row in step.Blocks(NodeTypes.Teach.Nudges.RepeaterCode))
{
var nudge = new Nudge { Order = _nudges.Count };
string does = null;
foreach (ResolvedField field in row)
{
switch (field.Code)
{
case NodeTypes.Teach.Nudges.After: nudge.After = field.Number() ?? 0; break;
case NodeTypes.Teach.Nudges.Does: does = field.Option(); break;
case NodeTypes.Teach.Nudges.Speaker: nudge.Speaker = field.Entity(); break;
case NodeTypes.Teach.Nudges.Line: nudge.Line = field.Text(); break;
}
}
// A row that says what it does: a pulse, or a line that has words.
if (does == "say" && !string.IsNullOrEmpty(nudge.Line)) _nudges.Add(nudge);
else if (does == "pulse") _nudges.Add(new Nudge { After = nudge.After, Order = nudge.Order });
}
// In the order they fire, the author's order among equals.
_nudges.Sort((a, b) => a.After != b.After ? a.After.CompareTo(b.After) : a.Order.CompareTo(b.Order));
}
private static double NumberFor(PlayStep step, string rule)
{
switch (rule)
{
case Rule.Held: return step.Field(NodeTypes.Teach.HeldFor)?.Number() ?? 0;
case Rule.Moved: return step.Field(NodeTypes.Teach.MovedFor)?.Number() ?? 0;
case Rule.Repeated: return step.Field(NodeTypes.Teach.Times)?.Number() ?? 1;
case Rule.DontWait: return step.Field(NodeTypes.Teach.ShownFor)?.Number() ?? 0;
default: return 0;
}
}
private bool AllDown()
{
if (_actions.Count == 0) return false;
foreach (string action in _actions)
{
if (!Game.IsDown(action)) return false;
}
return true;
}
private bool AnyPressed()
{
foreach (string action in _actions)
{
if (Game.WasPressed(action)) return true;
}
return false;
}
private bool ChordPressed() => AllDown() && AnyPressed();
private bool AnyMoving()
{
foreach (string action in _actions)
{
if (Game.MoveAmount(action) >= MoveThreshold) return true;
}
return false;
}
private sealed class Nudge
{
public double After;
public int Order;
public ResolvedEntity Speaker;
public string Line;
}
}
}

ITeachGame is yours. Map an action’s code to your own input action however your project does it — with the Input System, an action named by the code in your action asset — and answer GlyphFor from the binding the player has now, typically a TextMeshPro sprite tag for it; null draws the action’s name. The prompt is drawn through FieldMarkup with a mention hook that adds that glyph, so the same hook serves any line that mentions an action. How a glyph is treated is on drawing a line of text.

Your game drives it: hand over Game before the story starts, call Tick once a frame with the frame’s length, Dismiss when your pop-up closes, and RedrawPrompt when the controls change — a rebind, another device — so every action shows the binding the player has now.

The story moves on at the next Tick after the move is done, with Advance, which leaves by the first way out whose condition holds in the order the type lists them. Teach lists knows it first, gated on the remembering variable, so a player who already knows the move goes by it with no prompt at all, and everyone else goes by done. The variable is set after moving on, for the same reason: set first, a lesson just finished would leave by knows it.

A checkpoint and a retry

The Checkpoint marks where a retry puts the player back: the spawn point, what a retry keeps, the variable that counts the tries, and a line per try in the crew’s own voices, the first whose condition holds. There is no retry machinery in the engine. The handler saves the playthrough when the story arrives, and a retry — a death your game detects, or the retry() command a Code node calls when the story itself fails — restores that save, lays back what the checkpoint keeps, counts the try, and asks your game to put the player at the spawn point.

CheckpointNodeHandler.cs
using System.Collections.Generic;
using TaleShard.Docs.Samples;
using TaleShard.Generated.Dialogues.NodeTypes;
using TaleShard.Runtime.Engine;
using TaleShard.Runtime.Script;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
// Your game's half. The level and the player's body are the game's; the story only names
// where the player starts again.
public interface ICheckpointGame
{
// Reload what your level owns (the guards, the pickups) and put the player at the spawn
// point the story named: a variable's name, or a name the author typed in.
void RestartAt(string spawnPoint);
// One line of the way back in, for this try. The speaker is null for a narrator.
void Say(ResolvedEntity speaker, string line);
}
[CreateAssetMenu(menuName = "TaleShard/Node Handlers/Checkpoint")]
public sealed class CheckpointNodeHandler : TaleShardNodeHandler
{
// The handler holding the latest checkpoint. The story's retry() command reaches a hook
// that holds no reference to your scene, so it asks through this.
public static CheckpointNodeHandler Latest { get; private set; }
// Your game, handed over before the story starts: an asset cannot hold a scene object.
public ICheckpointGame Game { get; set; }
// One player's checkpoint: give each player that runs at the same time its own asset.
private TaleShardPlayer _player;
private PlaythroughSave _snapshot;
private string _boardId;
private string _nodeId;
private string _spawnPoint;
private string _triesIn;
private readonly List<string> _keeps = new List<string>();
private readonly List<ResolvedEntity> _keepsThings = new List<ResolvedEntity>();
private readonly HashSet<string> _seenSteps = new HashSet<string>();
private string _leaveFrom;
private bool _retryAsked;
private CommandCompletion _storyWaiting;
private bool _restoring;
public override string NodeType => NodeTypes.Checkpoint.TypeId;
public override void OnNodePlayed(TaleShardPlayer player, PlayStep step)
{
// Each step once. A language change, a redraw, Back and Forward present the same
// step again, and none of them is an arrival.
if (!_seenSteps.Add(step.Id)) return;
// The step this handler's own retry puts back. Tick finishes that retry: counting
// the try here would count it before the kept values are laid back.
if (_restoring) return;
// An arrival. Keep what a retry needs.
_player = player;
_boardId = step.Node.BoardId;
_nodeId = step.Node.Id;
ResolvedField? restartAt = step.Field(NodeTypes.Checkpoint.RestartAt);
_spawnPoint = restartAt?.Variable()?.Name ?? restartAt?.Text();
_triesIn = step.Field(NodeTypes.Checkpoint.TriesIn)?.Variable()?.Name;
_keeps.Clear();
ResolvedField? keeps = step.Field(NodeTypes.Checkpoint.Keeps);
if (keeps != null)
{
foreach (VariableRef kept in keeps.VariableList()) _keeps.Add(kept.Name);
}
_keepsThings.Clear();
ResolvedField? things = step.Field(NodeTypes.Checkpoint.KeepsThings);
if (things != null) _keepsThings.AddRange(things.Entities());
// The first try, written BEFORE the save so the snapshot already counts it.
if (_triesIn != null) player.Story.Runtime.SetValue(_triesIn, RuntimeValue.Of(1d));
_snapshot = player.Save();
Latest = this;
SayTheLineForThisTry();
_leaveFrom = step.Id;
}
// A death your game detected: call it with no argument. The story's retry() passes the
// wait it is holding, and the story stays on the failure until Tick has put the checkpoint
// back; the restore ends that wait. It only ASKS, and false means there is no checkpoint
// to return to. Tick does the work, outside any story callback.
public bool Retry(CommandCompletion storyWaiting = null)
{
if (_snapshot == null) return false;
_retryAsked = true;
_storyWaiting = storyWaiting;
return true;
}
// Call once a frame from your own game loop, never from inside a story callback.
public void Tick()
{
if (_player == null) return;
if (_retryAsked)
{
_retryAsked = false;
CommandCompletion waiting = _storyWaiting;
_storyWaiting = null;
// A stale wait is a story that was stopped, restarted or loaded meanwhile: it is
// not pulled back to the checkpoint.
if (waiting == null || !waiting.IsStale) RetryNow();
}
if (_leaveFrom == null) return;
string stepId = _leaveFrom;
_leaveFrom = null;
// Only from the checkpoint's own step; a story that has moved on is left alone. The
// story leaves as any step is left, by the first way out whose condition holds, so a
// skip gated on the tries opens once the count reaches it. A checkpoint whose way out
// is offered as a choice is waiting for the player, and Advance leaves it waiting.
if (_player.CurrentStep?.Id == stepId) _player.Advance();
}
private void RetryNow()
{
IScriptRuntime runtime = _player.Story.Runtime;
// 1. What the retry keeps, read now: the values as the player left them. A kept
// record keeps every field its definition declares.
var keptValues = new List<(string Code, RuntimeValue Value)>();
foreach (string code in _keeps) keptValues.Add((code, runtime.GetValue(code)));
var keptFields = new List<(string Entity, string Field, RuntimeValue Value)>();
foreach (ResolvedEntity thing in _keepsThings)
{
foreach (string field in thing.Fields.Keys)
{
keptFields.Add((thing.Code, field, runtime.GetEntityFieldValue(thing.Code, field)));
}
}
double tries = _triesIn == null ? 0 : RuntimeValues.ToNumber(runtime.GetValue(_triesIn));
// 2. The snapshot's position and state, with what stays the player's: the dice (a retry
// that rolled the same numbers would be a replay), the language they read in and,
// when the story can be saved, the lines already said and the choices already
// taken. A story waiting on a command cannot be saved, and the story's own retry()
// is such a wait, so a retry it asked for puts those two back with the rest.
PlaythroughSave now = _player.IsSuspended ? null : _player.Save();
RuntimeStateSnapshot state = _snapshot.State;
var back = new PlaythroughSave(
_snapshot.Position,
_snapshot.History,
now != null ? now.TakenPorts : _snapshot.TakenPorts,
new RuntimeStateSnapshot(
state.Variables,
state.EntityOverlay,
state.Instances,
state.VisitedCounts,
runtime.CaptureState().RngState,
state.CurrentNodeId),
_player.LanguageKey,
now != null ? now.SaidRows : _snapshot.SaidRows);
// 3. Put the checkpoint back. Its step is presented again from inside this call, and
// OnNodePlayed lets it pass. The restore also abandons whatever the story was
// waiting on, retry() included, so that wait is over and is never completed.
_restoring = true;
try
{
_player.Restore(back);
}
finally
{
_restoring = false;
}
// 4. Lay the kept values back where the restore changed them, and count the try.
foreach (var kept in keptValues)
{
if (!runtime.GetValue(kept.Code).Equals(kept.Value)) runtime.SetValue(kept.Code, kept.Value);
}
foreach (var kept in keptFields)
{
if (!runtime.GetEntityFieldValue(kept.Entity, kept.Field).Equals(kept.Value))
{
runtime.SetEntityFieldValue(kept.Entity, kept.Field, kept.Value);
}
}
if (_triesIn != null) runtime.SetValue(_triesIn, RuntimeValue.Of(tries + 1));
// 5. Your game's half: its level, and the player's body.
Game?.RestartAt(_spawnPoint);
// 6. The line for this try, and the way on from the step the restore put back.
SayTheLineForThisTry();
_leaveFrom = _player.CurrentStep?.Id;
}
// Asked of the node now rather than read off the step: the step was drawn as the story
// arrived, before the try was counted, so its line would be one try behind.
private void SayTheLineForThisTry()
{
IReadOnlyList<ResolvedField> line = _player.Engine.NextLine(_boardId, _nodeId);
if (line == null) return;
ResolvedEntity? speaker = null;
string text = null;
foreach (ResolvedField field in line)
{
switch (field.Code)
{
case NodeTypes.Checkpoint.Lines.Speaker: speaker = field.Entity(); break;
case NodeTypes.Checkpoint.Lines.Line: text = field.Text(); break;
}
}
if (!string.IsNullOrEmpty(text)) Game?.Say(speaker, text);
}
}
}
namespace TaleShard.Generated.Commands
{
// Your half of the generated command handler, for the retry() command the pack brings: a
// failure inside the story ends in a Code node calling it. It is AWAITED, so the story waits
// on the failure rather than walking on past it.
public partial class CommandHandler
{
public override void OnRetryAsync(CommandCompletion completion)
{
CheckpointNodeHandler checkpoint = CheckpointNodeHandler.Latest;
// No checkpoint to return to: end the wait at once, and the story carries on.
if (checkpoint == null || !checkpoint.Retry(completion)) completion.Complete();
}
}
}

ICheckpointGame is yours: RestartAt reloads what your level owns — the guards, the pickups — and moves the player to the spawn point, which arrives as the name the story gave it: a variable’s name, or a name the author typed in. Hand over Game before the story starts, call Tick once a frame, and call Retry() when the player dies. The retry() override at the bottom of the file is your half of the generated command handler.

retry() is an awaited command, so its hook is the awaited form, OnRetryAsync, with the completion token — see a command that takes time. The story waits on the failure instead of walking on past it into an ending. The override hands the wait to the handler; when there is no checkpoint to return to, it completes the wait at once and the story carries on from the failure. Without the override the project still builds; a story that calls retry() fails that wait with an error naming OnRetryAsync and carries on past the failure — see a command you have not overridden.

Three traps, each handled rather than left to you:

  • A restore is never run from inside the story. The hook is called while the engine is still inside the command, so Retry only asks, and Tick restores from your game loop. The restore abandons whatever the story was waiting on — retry()’s wait included — so that wait is over and is never completed. A wait that went stale meanwhile, because the story was stopped, restarted or loaded, is not pulled back to the checkpoint.
  • Coming back is not arriving. The restore presents the checkpoint’s step again from inside its own call; the handler lets that step pass, so it neither resets the count to one nor saves over the snapshot. A language change, a redraw, Back and Forward present a step already seen, and are ignored the same way.
  • The line is read after the count. A step is drawn as the story arrives, before the handler has written the try, so its own line would be one try behind. The handler asks the node for its line with Engine.NextLine once the count is written, and hands it to your game.

What a retry keeps is the value of every keeps variable and every declared field of every keepsThings record, as the player left them, laid back only where the restore changed them. The other variables and records, and which nodes were visited, go back to the moment the story arrived, so a first time here line plays again. The handler restores a copy of the snapshot that keeps the random generator as it is now — a retry that rolled the same numbers would be a replay — and the language the player reads in. It keeps the lines already said and the choices already taken too, but only when the story can be saved: a story waiting on a command cannot, and the story’s own retry() is such a wait, so a retry the story asks for puts those back with the rest. A death your game calls in during play keeps them. The try count is always written.

As with Teach, the handler says the try’s line itself, so declare the type’s presentable fields silent in your policy and list the type in AlwaysPresentNodeTypes. It moves past the checkpoint at the next Tick, by the first way out whose condition holds in the type’s order. Checkpoint lists skip first, so a skip gated on the tries is taken there once the count written by the retry reaches it, and on otherwise.

Next

Entity display: name, colour, portrait.

Written for
Unity developers
Last updated