Skip to content

Starting a session

TaleShardPlayer, what it needs, its two construction seams, and why it is not a MonoBehaviour.

All documentation — Playing a story
On this page

A session is three things wired together: the payload, the script runtime, and the traversal engine. A project that takes the generated bootstrapper gets that wiring for free. This page is for a game that wants to do it itself.

BuildASession.cs
using TaleShard.Docs.Fictions;
using TaleShard.Runtime.Engine;
using TaleShard.Runtime.Script;
using TaleShard.Unity;
using UnityEngine;
namespace TaleShard.Docs.Samples
{
public static class BuildASession
{
// playthroughId is the seed. Never a clock: two players on the same seed must get the
// same story.
public static TaleShardPlayer Begin(string engineJsonText, string playthroughId)
{
// Refuses rather than degrades. A missing file, a malformed body or an unsupported
// format version throws, rather than producing a project that plays nothing.
EnginePayload payload = EnginePayloadReader.Parse(engineJsonText);
EnginePayloadBinding binding = EnginePayloadBinding.Create(payload, seed: playthroughId);
// Surface these. An expression with no usable tree is a gate that will refuse at play
// time, and an unresolved condition variable reads as absent forever.
foreach (string id in binding.UnresolvedExpressions)
{
Debug.LogWarning("TaleShard: expression '" + id + "' has no usable tree.");
}
foreach (string code in binding.UnresolvedNamedExpressions)
{
Debug.LogWarning("TaleShard: condition variable '" + code + "' cannot be evaluated.");
}
// Your own command codes, registered freely. A command the editor declares is wired for
// you by the generated CommandHandler; do not register one of those here.
binding.Runtime.RegisterCommand("shake_camera", args =>
{
CameraRig.Shake(args[0].AsNumber());
return RuntimeValue.Null;
});
// onError is an argument rather than an event because applying the policy IS a check,
// and it runs inside the constructor — a handler attached afterwards is already late.
return TaleShardPlayer.Create(
payload,
binding.Runtime,
policy: new PlayerPolicy(),
language: null,
diagnostics: TaleShardPlayer.UnityConsole,
onError: error => Debug.LogWarning(error.Message));
}
}
}

The pieces

TypeWhat it is
EnginePayloadruntime.json, parsed by EnginePayloadReader. Every node arrives resolved — see node meaning is resolved once
IScriptRuntimethe playthrough’s state and the expression evaluator, built by EnginePayloadBinding: variables, the entity overlay, visit counts, the seeded generator, the save payload
DialogueEnginethe traversal. Its verbs are derived from structure — it names no node type anywhere
EngineSessionthe three above, wired
TaleShardPlayerthe Unity-facing wrapper over a session: policies, language, and a place to put your error handling

The engine takes two interfaces rather than a payload, which is why the same engine runs in the editor, on a share link and in your build — only where the facts come from differs.

Why TaleShardPlayer is not a MonoBehaviour

Because a session’s lifetime is not a scene’s lifetime. A story that survives a scene load, or two stories running at once, or a session built in a test with no scene at all, are all ordinary things to want — and none of them is expressible if the type is a component. A drop-in component exists for projects that do want one; it owns a player rather than being one.

The two construction seams

There are two ways to get a player, and the difference is who built the session. Both take a PlayerPolicy, which is how a game declares its presentation rules to the engine:

// You have a session already — the generated bootstrapper built one, say.
var player = new TaleShardPlayer(GameData.Story, policy, onError: ShowInMyOverlay);
// You are building both. The same argument is last.
var player = TaleShardPlayer.Create(payload, runtime, policy, language: "it", onError: ShowInMyOverlay);

One policy setting can only be honoured by Create: the one that asks the engine to offer a board’s remaining ways in when a path ends. The engine takes that as a construction argument and never re-reads it, so a player attached to a session that already exists cannot deliver it — and rather than accepting the setting and quietly ignoring it, the constructor refuses with a message naming both routes.

Command codes you register here are your own. The ones the editor declares are already wired by the generated CommandHandler, and registering over one of those silently replaces it — see implementing commands.

Diagnostics

The default differs by route, deliberately:

  • TaleShardPlayer.Create defaults to a Unity console sink.
  • EngineSession.Create and EngineSession.FromDirectory default to collecting, and that default cannot be changed — the runtime assembly contains no using UnityEngine, which is what keeps the engine headlessly testable.

For a Unity host, collecting amounts to discarding: the entries land on a list reachable only by downcasting. So a hand-built session that passes nothing reports nothing. Pass TaleShardPlayer.UnityConsole — it is public precisely so the other routes can name it — or your own delegate sink if you want the entries as data for an in-game overlay or a bug report.

Six things that are easy to get wrong

  • Start() is not a restart. It clears history, moments, the roll-forward stack, the call stack, taken ports and said lines, and leaves the runtime holding the previous playthrough’s values. Reset the runtime first if a restart is what you mean.
  • Start at the author’s start node, not just the board. EngineSession.Start() does this. Reading only the start board falls back to a derived entry and often opens a different scene than the editor shows; the editor writes both in one transaction.
  • A board’s derived entry is a root with an outgoing edge, not merely a root — a real board is full of isolated strays and every one of them is a root. The graph source already applies that rule; do not re-derive it.
  • A transfer node produces no step. A node that carries the playthrough into another board is traversal, not presentation, so it looks like nothing happened. That is correct.
  • Advance() on a step awaiting a choice does nothing, and selecting a choice on a step awaiting an advance does nothing. Read the step’s Awaiting flag rather than guessing from the verb.
  • Back() past the first step still rewinds the variables. The state as of before the first step is captured, so navigation and rollback cannot disagree.

Language

A host deals in language codes. The engine’s storage key stops at this boundary, and the distinction matters: a project whose master language is English keys that master content under a default key rather than under en, so the engine’s language is that key while the player is reading English. Handing the storage key to something expecting a code asks for a localization file the generator never writes, and every label renders as its raw key.

You wantUse
the language the story is being read inthe player’s Language — the project’s code
to follow it in your own chromeits LanguageChanged event, which publishes the code
to build a pickerits Languages list: a label to draw and a code to hand back
to change itits SetLanguage — every drawn step is re-resolved before the call returns
to talk to the engine’s view directlyits LanguageKey, the only member on the type that speaks storage spelling

The player’s Language is empty when the project never named a master language. That is a real answer rather than a gap to paper over — inventing "en" would put the wrong language on a story not written in it.

Starting from something in the world

A game usually starts the story from an event: the player talks to someone, walks into a room. The author marks the nodes the story can begin at as entry points and writes, on each, what fires it. StoryEntryIndex collects every entry node of the payload under a key your game derives from the node’s own fields, and resolves a key to the first entry whose gate passes — an entry’s gate is its first condition field, the same rule the engine applies to a board’s ways in. What the key means is yours; key by a tuple rather than by joining strings, because any separator an author can type makes two different pairs collide.

WorldEntries.cs
using TaleShard.Runtime.Engine;
using TaleShard.Runtime.Script;
namespace TaleShard.Docs.Samples
{
public static class WorldEntries
{
// Built once per payload. The key is the game's: here, what happened and who did it,
// read from the entry node's own fields by their codes.
public static StoryEntryIndex<(string Trigger, string Who)> Build(EnginePayload payload) =>
StoryEntryIndex<(string Trigger, string Who)>.Build(
payload,
(EngineNode node, out (string Trigger, string Who) key) =>
{
string? trigger = StoryEntryIndex<(string, string)>.TextOf(node, "trigger");
string? who = StoryEntryIndex<(string, string)>.TextOf(node, "initiatorCode");
key = (trigger ?? "", who ?? "");
return !string.IsNullOrEmpty(trigger) && !string.IsNullOrEmpty(who);
});
// The player talked to the baker: start the first way in whose gate passes, if any.
public static bool Fire(
StoryEntryIndex<(string Trigger, string Who)> entries,
DialogueEngine engine,
IScriptRuntime runtime,
string trigger,
string who)
{
StoryEntry? entry = entries.Resolve((trigger, who), runtime);
if (entry == null) return false;
engine.Start(entry.Value.BoardId, entry.Value.NodeId);
return true;
}
}
}

An entry whose condition ships no expression tree can never open; Build keeps it, marked ConditionUnusable, and reports it to the diagnostics you pass.

Next

Reading a step — what the engine hands you, and how to read it.

Written for
Unity developers
Last updated