Skip to content

Node meaning is resolved once

Why the engine never re-derives what a node means, and why a type-to-field-keys table is not something anyone can write.

All documentation — Concepts
On this page

An author invents their own node types. That is the product’s central affordance, and it is also the reason this plugin is shaped the way it is.

The rule, in one line

Type → field code is generated and supported. Type → field key is impossible.

The first is the NodeTypes constants class: it exists, your game is meant to use it, and step.Field(NodeTypes.Dialogue.Speaker) is the intended way to read a field. The second is what a generated class-per-node-type would need, and it is the thing nobody can write — including us. The rest of this page is why.

The table that cannot exist

Suppose you wanted the obvious API: a class per node type, with a property per field. To generate it you would need a function from a type to that type’s field keys.

Field values are addressed by minted ids — created when the author created the type — and a field inside a repeater block is keyed by combining the repeater’s id, the instance’s id, and the field’s id. The instance id exists only inside that one node’s own data. So the keys for a node of type Dialogue are not a property of Dialogue; they are a property of that node.

There is no function. It is not that writing one would be laborious — the information required is not in the type.

So the answer is computed once, upstream

The sync server resolves every node using the same TypeScript the browser player runs, and writes the answer into runtime.json. Per node it records the stored type id, the author’s code for it, and whether the projection could shape it at all — plus, for each field of that instance: its key, its code, its label, its type, which store the value came from, whether a player reads it, and its value, already read.

References are resolved in the same pass. An entity reference arrives as the entity; a media reference arrives with the address the bytes are at. A consumer performs no lookups of its own.

Rich text is rendered in the same pass too, per language, to both HTML and plain text. There is no document model downstream of the sync — by the time the engine sees a line, it is a string.

What this buys, and what it costs

It buys a second engine for free. Because the meaning is in the data rather than in the reader, a second implementation only has to walk a graph and evaluate trees. It never has to know what a Dialogue is. That is why the C# and C++ engines are each a port of the browser’s traversal rather than a reinterpretation of the same project, and why the two can be held to identical step sequences by a shared fixture suite.

It costs you the typed API you expected. A step arrives as a PlayStep carrying ResolvedField values, each with a FieldKind and its references already narrowed to types like MediaRef. There is no node.As<DialogueNode>(), and there will not be, because a projection of that shape selects a class by type name and every user-authored type is invisible to it. Instead:

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;
}
}
}

That sample returns strings, because it is showing the read surface. A real game does more than describe a step, and what it does is usually different per node type — start a fight for one type, draw a portrait for another. TypeId is the right key for that: it selects a card, a prefab, a layout, or the handler that owns one type’s logic. What it must not do is decide what a field means, because that is the decision the payload already made.

An unknown field type is not an error

A field type is additive in the editor, and adding one does not bump the payload’s format version. So a newer producer can legitimately hand a payload to an older plugin, and when it does, the field arrives with its kind reading Unknown while its raw type string still names it and its text accessor still works. The runtime raises one diagnostic per distinct unknown type at load, rather than one per field, because the latter is a synonym for silence.

An unsupported node, however, is refused and reported. A node the projection could not shape is never walked through — the payload marks it, and the engine records how many branches it discarded rather than pretending the path continued.

Written for
Unity developers · Advanced integrators
Last updated