What a step contains
ResolvedField and its typed accessors, choices and why they are blocked, entities, actors, and the five reference value types.
All documentation — Reference
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
On this page
A PlayStep hands you fields, choices, a cast and some audio. This page is
what each of those is.
ResolvedField
TaleShard.Runtime.Engine.ResolvedField — sealed.
One field of a node, or of an entity, resolved against the current state.
What it tells you about itself
| Member | Type | Notes |
|---|---|---|
Kind | FieldKind | What this field holds. Switch on this, not on RawType. |
Code | string | The author-facing code |
Label | string | |
Key | string | The exact storage key. A host does not normally read one. |
Presentable | bool | Is this a field a player reads? |
NonEmptyText | bool | Does it actually carry something to read? |
Localized | bool | True when the value came from the active language; false when it is a fallback |
RawType | string | The field type exactly as it arrived, so an unknown kind is still nameable |
Storage | FieldStorage | Where the value lives in the stored document |
References | IReadOnlyList<ResolvedRef> | Every reference this field holds. Never null. |
ExpressionId | string? | Pass it to IScriptRuntime.EvaluateCondition to evaluate this field as a gate |
Rich | RichRender? | |
Line | LineId? | |
Repeater | EngineRepeaterRef? |
The typed accessors
None of these throws. A scalar accessor returns null on the wrong shape; a list accessor returns empty. They do not coerce.
| Method | Returns | Notes |
|---|---|---|
Text() | string? | The rich text if any, else a string value. A number reads as null. |
Number() | double? | A numeric string is not parsed — see below |
Bool() | bool? | Neither "true" nor 1 counts |
Vector() | Vec3? | A three-member all-number list |
Point() | Point2? | A two-member all-number list: a position on a picture |
Rect() | Rect2? | A four-member all-number list: an area on a picture |
Date() | DateTimeOffset? | The instant a date field stores, in UTC; a date with no time is that day’s UTC midnight. Kind-checked. |
Time() | TimeOfDay? | A time field’s "HH:MM". Kind-checked; a value that does not parse is null, never midnight. |
Color() | Rgba? | A color field’s hex as four bytes, by the same rule every colour read uses. Kind-checked. |
List() | IReadOnlyList<RuntimeValue> | A non-list value reads as a one-element list; null reads as empty |
Option() | string? | The stored option value of a select field. Kind-checked. |
Options() | IReadOnlyList<string> | Every stored option of a multi-valued select |
HasOption(string value) | bool | Compares the stored value, ordinally — not a display label |
Source() | string? | The source text of a code or condition field. Kind-checked. |
Entity() | ResolvedEntity? | The first entity referenced. Correct for one and for many. |
Entities() | IReadOnlyList<ResolvedEntity> | |
Items() | IReadOnlyList<ItemCount> | An itemCounts field’s entries: each referenced entity paired with the count at the same position of the value — and with its cell, when the field has a grid (ItemSlots) — in the author’s order, duplicates kept. Kind-checked. |
ItemSlots | IReadOnlyList<int?>? | An itemCounts field’s cells, paired position for position with its references: the cell of the inventory grid each entry sits in, or null for an entry with none. Null unless some entry has a cell. Items() already pairs it. |
Capacity | int? | An itemCounts field’s grid capacity — how many cells it has, as the field’s declaration states it. Null when the field is unbounded, and on every other kind of field. Every cell in ItemSlots is below it. |
Columns | int? | How many cells wide an itemCounts field’s grid is, as its declaration states it. Null when the grid has no fixed width, and on every other kind of field. Cells count row by row, so an entry sits at row Slot / Columns, column Slot % Columns. |
Media() / MediaList() | MediaRef? / list | |
Tag() | TagRef? | |
Variable() | VariableRef? | The variable a field NAMES — which spawn point, which flag. Code is the name the runtime is keyed by, ContextCode its context’s or null, and Name the two joined with a dot. Null when the field names none, or names one the project no longer has. A field may also be declared to accept a name the author TYPED, and a typed name arrives as the field’s Text() with no reference at all — so on such a field read field.Variable()?.Name ?? field.Text(). |
VariableList() | list of VariableRef | Every variable a field declared as a LIST of variable links names, in the author’s order; empty when it names none. A variable the project no longer has is left out. A list field never holds a typed name. |
NodeTarget() | NodeRef? | |
BoardTarget() | BoardRef? | |
Command() | CommandRef? | |
AsRuntimeValue() | RuntimeValue | The raw value, unconverted — the handle for the expression language’s reading, which RuntimeValues.ToNumber (a list reads as its length) and ToBool apply |
Option() and Source() are kind-checked where most accessors are not, and for a reason worth
knowing: an option names a vocabulary, so a free string from a text field could otherwise be compared
against a generated enum member and pass on entirely the wrong field. A gate’s source, likewise, must
never be readable as a line of dialogue.
Prefer the accessors over References and Value. Those are the raw forms; the accessors exist so
you never walk a reference list and switch on its kind yourself.
Which rows of a list reach a step
A field whose Repeater is set belongs to one row of a list. For a read list — one whose rows
play as beats rather than as exits — the engine has already chosen which rows the step shows, in
this order:
- The row’s own condition and its Once tick. A row whose condition failed is dropped whole. A
row ticked Once (
Repeater.Once) that has already been said is dropped the same way, before its condition is asked. - Taken by. When the node picks its track row by a variable (
EngineNode.TakenBy), only the row that rule takes is kept: a row with no start whose condition holds, else the first row whose span holds the variable’s value. No row taken keeps none. - Plays (
Repeater.Plays)."first"keeps the holding row with the lowestIndex;"random"keeps one drawn byWeight;"leastRecent"keeps the one said longest ago, a row never said before any row that was, the lowestIndexamong equals — and while any holding row carries a condition (a non-blankGate), only those rows compete, so an “any time” row waits. Absent keeps every holding row.
What a row having been said means: a step presented it, or a NextLine call returned it. The
engine remembers it only for a list that plays "leastRecent" and for a row ticked Once, and
the memory is part of the save (SaidRows), so a line said once stays said across a load. A
rollback, a load or a redraw shows each moment as it was, with the memory as it stood when that
moment was drawn — and stepping back does not unsay a line, just as it does not untake a choice.
A line without a step: NextLine
DialogueEngine.NextLine(string boardId, string nodeId) returns IReadOnlyList<ResolvedField>?,
the same fields a step carries; a game reaches the engine as TaleShardPlayer.Engine. It asks a
node for its next line without entering the flow — for a line the world triggers rather than
the story: a guard spotting the player, a shopkeeper greeting them. No step is presented, the
story does not move, no script runs and no visit is counted, and it is legal whether the story
stands on this node, on another, or on none.
The node’s first read list answers — the first node-level list in declaration order whose rows are
read rather than ported and that has a row. Its rows go through the three rules above, and one row is returned: the
list’s Plays pick, or its first holding row when it has no Plays, so a list of Once lines reads
out in order, one per call. Under "random" the call consumes the generator’s next number, and only
when some row can play. The row is remembered as said, exactly as a presented one is. The return is
null for a node that does not exist, a type this engine cannot read, a node with no read list, or no
row that holds.
It is also null, with an engine-busy warning, while the engine is drawing. Drawing reports
what it could not read on the diagnostic channel, so game code can run in the middle of it, in one
of two windows:
- Re-drawing a past moment — during
SetLanguage,RedraworRestorePlaythrough, each of which draws every moment of the history again against that moment’s own state and said lines. ANextLineasked there would read and record against a past moment and lose its record when the live state is put back. - Drawing a live step — from the start of a node’s draw until its step is recorded and its row
draws are spent. The draw only looks at the numbers its rows are picked with and spends them once
the step is recorded, so a
NextLineasked there would take the number the step’s own row was picked with, and a redraw, a rollback or a load of that step could show a different line.
Ask again once the call that draws has returned. A NextLine asked from a step handler
(StepPresented) is answered: the step is recorded and spent before it is presented.
ResolvedChoice
One exit offered to the player.
| Member | Type | Notes |
|---|---|---|
ChoiceId | string | Key by this. |
PortId | string | Which handle it leaves by. Not unique within a step. |
Content | IReadOnlyList<ResolvedField> | |
PictureKey | string? | The key of the option’s face field — a media or entity reference on the step’s node, read with step.FieldByKey. Set only for an option from a repeater row whose block carries a picture; never content |
Enabled | bool | Every gate and requirement passed, or there were none |
IsTakeable | bool | Enabled && Target != null |
Target | ChoiceTarget? | Null for a dangling exit — a port with no edge, or an edge to a missing node |
Visited | bool | |
BlockedBy | IReadOnlyList<ChoiceBlock> | Every reason it is drawn but not takeable |
Read IsTakeable rather than re-deriving it. The engine uses the same rule to decide whether a
node offers a choice at all or merely presents.
ChoiceTarget is NodeId and BoardId.
Why a choice is blocked
ChoiceBlock carries structure only — there is no display copy in it, so your game decides what
to show.
| Member | Type | Notes |
|---|---|---|
Kind | BlockKind | Gate or Requirement |
Terms | IReadOnlyList<BlockedTerm> | What the player needs and what they hold |
TagLabel | string? | Resolved once, so you need no lookup. Requirements only. |
TagId | string? | Requirements only |
Reason | string? | The author’s own sentence, when they wrote one — a requirement row’s, or on a Gate the one written on the edge’s condition. An exit’s own gate never carries one |
NodeId | string? | Which node carries the requirement this was hoisted from |
Expression | string | For diagnostics. Never show it to a player. |
BlockKind distinguishes the two mechanisms, and a game usually draws them differently: a gate is
the author saying not down this path; a requirement is you cannot do this yet — which is the
one a player is owed an explanation for.
A BlockedTerm is a Name and its Values:
Nameis the variable that was checked, never the row’s tag.Valuesis what the player holds and what was wanted — two values, or none. A bare truth test has no second side.
Together they are what lets you render reputation 2 / 5 without knowing anything about the project.
There can be several, because a && b can fail twice.
Entities and actors
ResolvedEntity
An entity as the game sees it: the authored data and the playthrough overlay merged once, so every read path answers the same values.
| Member | Type | Notes |
|---|---|---|
Id · Code · Name | string | |
PictureId · Color | string? | |
Fields | IReadOnlyDictionary<string, ResolvedField> | Merged authored and overlay values, keyed by field name. Every field the definition has: those it inherits, those an applied trait adds (named with the trait’s prefix, stats.armor) and its own |
Intrinsics | IReadOnlyDictionary<string, ResolvedField> | $name, $code, $color, $voice, $description, $portrait — see below |
Overlaid | IReadOnlyList<string> | Which field names came from the overlay |
DefinitionId | string | |
MentionAs | EngineMentionLook? | How a mention of this entity draws, as its definition declares it: null for its name, else a keycap — the entity is an input action and its Code is what your game maps to its own action. See drawing an action’s glyph |
Intrinsics are the fields every entity has, whatever its definition. Bind those when a preset
has to work against a project that declares no entity definitions of its own.
$voice is the code of the voice this character is cast to, and it is absent when they are uncast.
It is what answers which voice speaks this line with no naming convention: the speaker is already
resolved per step, so the voice for a line is the speaker’s — which is why a voice clip on a step
carries that code rather than making you look it up.
ResolvedActor
An actor on stage for this step: Entity, ViaFieldKey, and IsSpeaker.
IsSpeaker is true for the actor of the first entity field, which is all the runtime can tell
structurally. Your game is free to decide otherwise.
ResolvedAudio
Channel, MediaId, Url, and — on the voice channel only — Line and VoiceCode. Channel
is what distinguishes this from an ordinary media reference.
The engine produces two channels:
| Channel | One entry per | Also carries |
|---|---|---|
sfx | field of the node holding a media reference whose file is a sound — its kind in MediaKind is audio. A picture, a video or a file of unknown kind is never here | — |
voice | line with a voice-over clip in the language you are playing in | Line, the same identity a translation and a recording script use; VoiceCode, the voice of the character who says that line — the field its EngineField.SpeakerKey names (in a conversation, that row’s own character, never one on its beat after the pause) |
music and ambience are part of the model and have no producer. Channel is a string rather than
an enum so a later one breaks no reader — give any switch on it a default.
Two voice entries on one step are two lines and belong one after the other, not on top of each
other. VoiceCode is absent when the speaker is uncast, and that is not a reason to withhold the
clip: a take exists, so it plays.
The value types
Five narrow structs rather than one general reference, so that a host cannot ask a reference of one kind for another kind’s members and read a meaningless null.
| Type | Holds |
|---|---|
MediaRef | Id; Url — where the bytes are, as the payload gave it, not an asset key; and Focal — where the picture’s face is, a Point2 in 0..1 of the picture’s box, or null when no face is marked. Crop toward it; centre when null |
TagRef | Id and Label. The label is the author’s own; the id is never a fallback for it. |
CommandRef | Code and Args — positional, one per parameter the command declares, as a dispatcher expects. An unfilled parameter is null; one the author bound to a variable holds that variable’s value as of the moment the step was composed. ArgVariables, aligned with Args, names each bound argument’s variable by its context-qualified name (empty for a literal, and an empty list when nothing is bound); ArgVariableName(i) answers one or null. To fire a held call later with its bound arguments read now, pass it to TaleShardPlayer.RereadCommandArguments (or EngineSession.RereadCommandArguments, or call.Reread(runtime)), which replaces each bound argument with the variable’s current value and copies the rest. On an entity’s commandCall field too |
NodeRef | NodeId, and BoardId which may be absent for a target on the same board |
BoardRef | BoardId |
Vec3 is three doubles, X/Y/Z. Deliberately not an engine vector type: you convert at your
own edge, which keeps this assembly free of engine references.
Point2 is two doubles, X/Y, and Rect2 four, X/Y/W/H — a position and an area
on a picture, each number a fraction 0..1 of the picture’s box from its top-left corner. Multiply
by the drawn picture’s size at your own edge; the same reasoning as Vec3 keeps them out of
UnityEngine’s types.
ItemCount is an Item — a ResolvedEntity, resolved like any entity reference when the
step is composed, so its values are the playthrough’s at that moment: for an entry an exit handed
over, after the exit’s scripts and the next node’s own scripts have run, exactly as the step’s
cast is read — an int Count of one or more, and an int? Slot: the cell of the inventory
grid the entry sits in, a whole number from zero, or null when it has none. It is one entry of an
itemCounts field (Items()), and one entry of what an exit handed over on the way to a step
(step.Trace.Received). Entries are the author’s: in order, duplicates kept, never merged. The
cell is the author’s too — the one the field’s card shows the item in when the field has a
capacity, the one she placed it in when it has not — so a game with a grid inventory seats the
item there, and a game without one ignores the number. The grid the cells count over is the
field’s own Capacity and Columns; a hand-over row has no grid, so a received entry’s Slot
is read against the grid of wherever the game puts it.
FieldKind
Switch on this rather than on RawType, where a typo resolves to nothing.
Text · RichText · Number · Boolean · Date · Time · Asset · Entity · Select ·
Color · Vector3 · Code · NodeRef · BoardRef · DocumentRef · VariableRef ·
CommandCall · TagRef · Condition · Position · Area · ItemCounts
Two companions in the same place:
FieldStorage—Unknown·Values·Fragment·CodeTextRefKind—Unknown·Entity·Media·Tag·Command·Node·Board·Variable.
Each has a Parse and a ToWire on a static helper — FieldKinds, FieldStorages, RefKinds —
which is how a wire name becomes a kind and back.
ResolvedRef
The raw form behind References. Its constructor is private; six factories build one —
ForEntity, ForMedia, ForTag, ForCommand, ForNode, ForBoard.
Its ten nullable properties are only meaningful after switching on Kind, which is exactly what the
typed accessors on ResolvedField already did. Reach for it only when you are writing something
generic over every reference kind.
Args is typed as RuntimeValue because no type reachable from PlayStep’s public surface may
come from a JSON library — a constraint worth knowing if you are extending this surface yourself.