Skip to content

Play your first story

The smallest program that starts a session and prints a line β€” no Unity, no MonoBehaviour, about thirty lines.

All documentation β€” Start here
On this page

This is the whole loop. It reads the synced payload, starts the story, prints each step’s text and advances until the story ends. Nothing here is Unity-specific, so you can run it as a console program before you wire anything into a scene β€” which is a much faster way to find out whether your project synced correctly than pressing Play.

FirstStory.cs
using System;
using System.Collections.Generic;
using TaleShard.Runtime.Engine;
using TaleShard.Runtime.Script;
namespace TaleShard.Docs.Samples
{
public static class FirstStory
{
// One argument: the folder the sync wrote runtime.json into.
public static void Main(string[] args)
{
string directory = args.Length > 0 ? args[0] : ".";
// The resolved project: boards, nodes, entities and parsed expressions. Nothing here
// parses story syntax β€” the editor did that at sync time.
EnginePayload payload = EnginePayloadReader.ReadDirectory(directory);
// Holds the project's variables, entities and enums, and evaluates the expression trees
// the payload carries. It also reports what it could not resolve.
EnginePayloadBinding binding = EnginePayloadBinding.Create(payload);
EngineSession session = EngineSession.Create(payload, binding.Runtime);
session.Start();
// A budget, not a `while (true)`: an authored loop is a story an author is allowed to
// write, and a sample that hangs on one teaches nothing.
for (int guard = 0; guard < 1000; guard++)
{
EngineState state = session.Engine.State;
if (state.Kind == EngineStateKind.Presenting)
{
Print(state.Step);
session.Engine.Advance();
continue;
}
if (state.Kind == EngineStateKind.AwaitingChoice)
{
Print(state.Step);
int index = FirstTakeable(state.Step.Choices);
if (index < 0) break;
session.Engine.SelectChoice(index);
continue;
}
break;
}
Console.WriteLine("-- " + (session.Engine.State.Reason ?? session.Engine.State.Kind.ToString()));
}
private static void Print(PlayStep step)
{
// `Presented` is the author's presentable fields, in authored order. The engine has
// already decided what belongs on screen, so a host never filters.
foreach (ResolvedField field in step.Presented)
{
// Answers the plain reading, or null when the field holds a number, a reference or
// nothing. It never throws and never coerces.
string text = field.Text();
if (text != null) Console.WriteLine(field.Label + ": " + text);
}
for (int i = 0; i < step.Choices.Count; i++)
{
ResolvedChoice choice = step.Choices[i];
Console.WriteLine(" [" + i + "] " + ChoiceText(choice) + (choice.IsTakeable ? "" : " (locked)"));
}
}
private static string ChoiceText(ResolvedChoice choice)
{
foreach (ResolvedField field in choice.Content)
{
string text = field.Text();
if (text != null) return text;
}
return choice.ChoiceId;
}
private static int FirstTakeable(IReadOnlyList<ResolvedChoice> choices)
{
for (int i = 0; i < choices.Count; i++)
{
if (choices[i].IsTakeable) return i;
}
return -1;
}
}
}

Run it against the folder the sync wrote:

Terminal window
dotnet run -- ./Assets/TaleShard/Generated

What each part is doing

Reading the payload. EnginePayloadReader refuses rather than degrades. A missing file, a malformed body, or a format version this build does not understand throws β€” because the alternative is a project that compiles, plays nothing, and looks like a content problem.

The binding. EnginePayloadBinding.Create builds the expression runtime from the same payload: the project’s variables, its entities, its enums, and the expression trees the editor parsed at sync time. It also reports what it could not resolve, and starting a session shows why you should surface that rather than discard it.

The loop. EngineSession wires the payload, the runtime and the engine together. The engine is a state machine with a small number of states, and the two you handle here are the two that need the player: Presenting wants an advance, AwaitingChoice wants a selection. Read State.Kind rather than guessing from the step β€” see reading a step.

The budget. The for loop has a bound rather than being while (true). An authored loop is a story an author is allowed to write, and a sample that hangs on one teaches nothing about either.

What this sample deliberately does not do

It picks the first takeable ResolvedChoice, which no real game does. It prints Label: text, which is a debugging shape rather than a presentation. And it ignores everything else a step carries β€” the cast, the audio channels, the variables snapshot. Reading a step is where those come in.

It also assumes the payload is a file on disk. In a Unity build the sync mirrors the payload into a Resources folder as a text asset, so a real game hands the text to EnginePayloadReader.Parse rather than pointing at a directory.

Next

Starting a session β€” the same thing with the wiring a real game needs: diagnostics, your own commands, and a presentation policy.

Written for
Unity developers
Last updated