Starting a session
TaleShardPlayer, what it needs, its two construction seams, and why it is not a MonoBehaviour.
All documentation — Playing a story
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
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.
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
| Type | What it is |
|---|---|
EnginePayload | runtime.json, parsed by EnginePayloadReader. Every node arrives resolved — see node meaning is resolved once |
IScriptRuntime | the playthrough’s state and the expression evaluator, built by EnginePayloadBinding: variables, the entity overlay, visit counts, the seeded generator, the save payload |
DialogueEngine | the traversal. Its verbs are derived from structure — it names no node type anywhere |
EngineSession | the three above, wired |
TaleShardPlayer | the 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.Createdefaults to a Unity console sink.EngineSession.CreateandEngineSession.FromDirectorydefault to collecting, and that default cannot be changed — the runtime assembly contains nousing 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’sAwaitingflag 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 want | Use |
|---|---|
| the language the story is being read in | the player’s Language — the project’s code |
| to follow it in your own chrome | its LanguageChanged event, which publishes the code |
| to build a picker | its Languages list: a label to draw and a code to hand back |
| to change it | its SetLanguage — every drawn step is re-resolved before the call returns |
| to talk to the engine’s view directly | its 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.
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.