Skip to content

Reading a step

PlayStep, ResolvedField and the accessors — the whole read surface, in one page.

All documentation — Playing a story
On this page

A step is a PlayStep. There is no step.Text on it and no step.Speaker: a project’s node types are authored by its own user, so there is no fixed set of keys to pre-extract — the reasoning is on node meaning is resolved once. A step carries resolved fields, and your game decides what they mean.

ReadAStep.cs
using System.Collections.Generic;
using TaleShard.Runtime.Engine;
namespace TaleShard.Docs.Samples
{
public static class ReadAStep
{
public static IReadOnlyList<string> Describe(PlayStep step)
{
var lines = new List<string>();
// TypeId is a dispatch key: it picks a card — a prefab, a layout — or the handler that
// owns this type's logic. What it must never do is decide what a field MEANS, which is
// the decision the resolved payload has already made.
lines.Add("[" + step.Node.TypeId + "] " + step.Verb);
foreach (ResolvedField field in step.Presented)
{
// Kind is a closed enum, not a string.
switch (field.Kind)
{
case FieldKind.RichText:
// Rendered at sync time. There is no document model downstream.
lines.Add(field.Label + ": " + field.Rich.Text);
break;
case FieldKind.Entity:
// Already looked up, and null-safe.
ResolvedEntity? who = field.Entity();
if (who != null) lines.Add(field.Label + ": " + who.Name);
break;
case FieldKind.Asset:
// MediaList() is right for one reference and for five. Media() takes the
// first, which is correct for one picture and wrong for five.
// Url is where the bytes are, never an asset key; Id is the id.
foreach (MediaRef media in field.MediaList())
{
lines.Add(field.Label + ": " + (media.Url ?? media.Id));
}
break;
default:
// An unknown Kind is not an error: a newer producer can legitimately reach
// an older plugin, and RawType still names the type.
string text = field.Text();
lines.Add(field.Label + " (" + field.RawType + "): " + (text ?? "-"));
break;
}
}
return lines;
}
}
}

What a PlayStep carries

MemberWhat it holds
Nodethe node’s id, its board, and its TypeId — a dispatch key: a card, or this type’s handler. Never what a field means
Verbwhat kind of moment this is, derived from structure. Choose means options are waiting
Fieldsevery field of this node instance
Presentedthe subset a player reads, in authored order. Use this to draw
Choicesthe exits, each with its own content and its own reasons for being locked
Castwho is on stage after this step, and which one is the speaker
Audioper channel. The engine produces sfx (the sounds a node’s media fields hold) and voice (the voice-over clip each line currently sounds with, in the language you are playing in)
Variablesa read-only snapshot, for a debug overlay
FlagsIsLatest, IsTerminal, and Awaiting

Presented already accounts for a node type that declares itself silent, and for a field the author marked as not presentable. That is the “is there anything to show” rule, applied once — do not invent a second one.

What a ResolvedField carries

MemberMeaning
Code / Labelthe author’s field code, and its human label
Kindthe field type, as a closed enum — FieldKind
RawTypethe wire string, kept so a type this plugin does not know is still nameable
Storagewhich store the value came from
Presentablewhether a player reads this field
Valuenever null; absence is a null value, not a null reference
Richthe rendered text, as HTML and as plain text
Referencesevery reference, already looked up. Empty rather than null
ExpressionIdthe parsed tree behind a gate or script field, when it has one
Linethe line’s identity — board, node, field key. Never a hash of the text
Localizedfalse means this value is a fallback, not the active language
Repeaternull for a node-level field; for a field inside a repeater block, the block’s RepeaterCode, its InstanceId and Index, and Ported — true when the block carries a source handle: its rows are the node’s exits, each row one of the step’s choices, so read the row as a choice, not as content
NonEmptyTextthe “is there anything to show” rule for one field

The accessors

An accessor naming a reading checks only the value’s shape. One naming a vocabulary or a reference checks the kind as well. None of them throws, every scalar returns null when the question does not apply, and every list is empty rather than null — there is no TryGet.

AccessorNon-null exactly when
Text()rich text, else a string value. Not kind-checked, because an author may present a select as a line. A number is not text
Number()the value is a number. A numeric string is not parsed — text-to-number is the one coercion that lies
Bool()the value is a bool. Not "true", not 1
Vector()a three-member all-number list
Point()a two-member all-number list — a position on a picture, X and Y each 0..1 of the picture’s box from its top-left corner
Rect()a four-member all-number list — an area on a picture, its corner X, Y and size W, H in the same space
Time()the field is a time field and its "HH:MM" string parses — a TimeOfDay with Hour, Minute and TotalMinutes. Kind-checked, unlike the readings above, because the stored shape is a plain string and a line of text that reads like a clock is not a time
List()the members. A scalar reads as a one-element list, absence as empty
Option(), Options(), HasOption(v)the field is a select. Kind-checked, so a free string from a text field cannot silently match a generated enum member
Source()the field is a code or condition field. Never Text() — a gate’s source is not a line of dialogue
Items()the field is an itemCounts field — entity items with a count each. Its referenced entities zipped with the counts in its value, position for position, as ItemCount entries (Item, Count, and Slot — the cell of the inventory grid the entry sits in, or null when it has none) in the author’s order, duplicates kept. Kind-checked; empty, never null. The grid those cells count over is the field’s own Capacity and Columns (each int?, null when unbounded or with no fixed width): a game draws Capacity cells Columns wide and seats an entry at row Slot / Columns, column Slot % Columns
Entity(), Entities(), Media(), MediaList(), Tag(), NodeTarget(), BoardTarget(), Command()the matching reference kind is present. Each returns its own narrow type — MediaRef for media, and so on — so one kind of reference cannot be asked for another kind’s members

Prefer the plural accessors. Entities() and MediaList() are correct for one reference and for five; the singular ones take the first, which is right for one portrait and wrong for five.

A MediaRef carries Id, Url and Focal. Focal is where the picture’s face is — a Point2, each coordinate a fraction 0..1 of the picture’s box from its top-left corner, marked once per picture in the editor’s media tab — or null when no face is marked. A presenter that crops the picture (a portrait frame, a banner) crops toward Focal, and about the centre when it is null. The same picture answers the same point wherever it is referenced: a node’s asset field, a character’s $portrait, an entity’s own asset field.

Naming a field without a magic string

The generator emits a constants class — one nested class per node type, carrying the type’s id, its code, and a constant per field holding the author’s field code:

// Compile-checked in YOUR project. Rename `speaker` in the editor and this line
// stops compiling, instead of returning a silent null in a shipped build.
var speaker = step.Field(NodeTypes.Dialogue.Speaker)?.Entity()?.Code;
var line = step.Field(NodeTypes.Dialogue.Text)?.Text();

A repeater is a nested class, which is what tells two similarly-named fields apart: a node’s own condition field and a per-row condition field inside a repeater have different constants.

The same thing, wired into a scene

The sample above returns strings so it can be read without Unity in the way. Here is the shape a real presenter takes — one step in, one screen out:

LinePresenter.cs
using TaleShard.Docs.Fictions;
using TaleShard.Runtime.Engine;
using TaleShard.Unity;
using UnityEngine;
using UnityEngine.UI;
namespace TaleShard.Docs.Samples
{
public sealed class LinePresenter : MonoBehaviour
{
[SerializeField] private Text _line;
[SerializeField] private Text _speaker;
public void Present(PlayStep step)
{
_line.text = "";
// Presented, not Fields: the engine has already decided what belongs on screen.
foreach (ResolvedField field in step.Presented)
{
string text = field.Text();
if (text == null) continue;
_line.text += text + "\n";
}
// Cast is already resolved, and it says who is speaking — so there is no need to
// search the fields for an entity and guess.
if (step.Cast.Count > 0)
{
EntityDisplay display = EntityDisplay.Of(step.Cast[0]);
_speaker.text = display.Name;
_speaker.color = display.ColorOr(Color.white);
}
// Your code, not the plugin's.
SpeechBubble.Show(_line.text);
}
}
}

Two things it is doing on purpose. It reads Presented rather than Fields, so the engine’s own decision about what belongs on screen is the one that applies. And it takes the first cast member as the speaker rather than searching the fields for one — Cast is already resolved, and it says which member is speaking.

SpeechBubble there is not plugin API. It stands in for your own presentation code, and it is declared in a shim namespace on this site so that is visible rather than implied — see entity display for what EntityDisplay gives you.

Speaking the line

step.Audio is what this step plays rather than draws, one entry per channel. The engine fills two: sfx, one per field of the node holding a media reference whose file is a sound (a picture or video on the node is never here), and voice, one per line that has a voice-over recording in the language you are playing in. Switch on Channel, never on the node type — audio is a channel, not a node type — and give the switch a default, because Channel is a string so that a later channel breaks no reader.

SpokenLine.cs
using TaleShard.Runtime.Engine;
namespace TaleShard.Docs.Samples
{
public static class SpokenLine
{
/// <summary>
/// The clip this step speaks, or false when it says nothing aloud — which is every step
/// in a project with no voice-over.
/// </summary>
public static bool Find(PlayStep step, out ResolvedAudio spoken)
{
spoken = null;
foreach (ResolvedAudio audio in step.Audio)
{
// Switch on the channel, never on the node type. Audio is a channel: sfx is the
// sound a node's fields hold, voice is the recorded line. A channel is a string
// so a later one breaks no reader, so anything else is skipped rather than
// guessed at.
if (audio.Channel != "voice") continue;
// The first one. Two voice entries on one step are two lines and belong one
// after the other; queueing them is your game's pacing decision.
spoken = audio;
return true;
}
return false;
}
/// <summary>Play it, or say why not. Audio never blocks a story.</summary>
public static string Play(PlayStep step, IVoiceMixer mixer)
{
ResolvedAudio spoken;
if (!Find(step, out spoken)) return null;
// VoiceCode is the speaking character's voice, for a per-character mute. It is absent
// when they are uncast, and that is not a reason to withhold the clip: a take exists,
// so it plays.
if (spoken.VoiceCode != null && mixer.IsMuted(spoken.VoiceCode)) return null;
// MediaId, never a path and never a filename. Resolve it the way you resolve a
// portrait: MediaAddress.From(spoken.MediaId), then your media library.
//
// Line is the same identity a translation and a recording script use, so a caption or
// a replay keys off it rather than off the field's position.
mixer.Speak(spoken.MediaId, spoken.Line.ToString());
return spoken.MediaId;
}
}
/// <summary>Your game's audio, not the plugin's.</summary>
public interface IVoiceMixer
{
bool IsMuted(string voiceCode);
void Speak(string mediaId, string lineId);
}
}

A voice entry carries two things an sfx entry does not. Line is the line the clip belongs to — the same identity a translation and a recording script use, so a caption or a replay keys off it rather than off the field’s position. VoiceCode is the voice of the character who says that line — in a conversation, that row’s own character — which is what a per-character mute keys off; it is absent when they are uncast, and that is not a reason to withhold the clip.

The clip itself is addressed by MediaId and resolves exactly as a portrait does — MediaAddress into your media library. MediaPlan.ForStep already includes it, and MediaPlan.ForBoard includes a board’s clips when you pass the language you are playing in.

Trace

A step carries a trace — which scripts ran, which gates were evaluated, and how many branches were discarded as unsupported. It is deliberately not documented in detail here: the trace’s gate records do not currently mean the same thing in this runtime as they do in the editor’s play mode, so a page describing them would be describing a divergence rather than a contract. Use it for debugging, not for logic, until that is closed.

Next

Choices, and why one is locked.

Written for
Unity developers
Last updated