Choices, and why one is locked
Advancing, selecting, and drawing an option the player can see but cannot take — including the progress reading behind it.
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
When a PlayStep is awaiting a choice, its Choices list holds one ResolvedChoice per exit the
player can see. Some of
them may not be takeable, and drawing a locked option well is most of what makes a requirement feel
like part of the game rather than a bug.
using System.Collections.Generic;using TaleShard.Runtime.Engine;
namespace TaleShard.Docs.Samples{ public static class LockedChoices { public static IReadOnlyList<string> Describe(PlayStep step) { var lines = new List<string>();
foreach (ResolvedChoice choice in step.Choices) { // One rule for takeable, already applied: enabled, and pointing somewhere. lines.Add((choice.IsTakeable ? "[ ] " : "[x] ") + Text(choice) + (choice.Visited ? " (seen)" : ""));
// A list, because a choice can be refused by its own gate AND by requirement rows // on the node it leads to. Empty means takeable. foreach (ChoiceBlock block in choice.BlockedBy) { // The author's own sentence, when they wrote one. Prefer it over anything you // compose: they know why the door is shut. if (block.Reason != null) { lines.Add(" " + block.Reason); continue; }
// Already looked up. This is what you draw as [STRENGTH]. string badge = block.TagLabel ?? block.Kind.ToString();
// Values is the player's side then the wanted side, or empty — a bare truth // test has no second side. Several terms, because `a && b` fails twice. foreach (BlockedTerm term in block.Terms) { string detail = term.Values.Count == 2 ? term.Name + " " + term.Values[0] + "/" + term.Values[1] : term.Name; lines.Add(" [" + badge + "] " + detail); }
// An empty Terms is a real answer: a bare false gate has nothing to name. if (block.Terms.Count == 0) lines.Add(" [" + badge + "]"); } }
return lines; }
private static string Text(ResolvedChoice choice) { // The destination lends a choice its text. The rule is already applied upstream, so // read Content rather than deriving which fields supply it. foreach (ResolvedField field in choice.Content) { string text = field.Text(); if (text != null) return text; } return choice.ChoiceId; } }}Taking one
Select by index into Choices. Selecting an index that is out of range, or one whose choice is
not takeable, does nothing rather than throwing — the engine refuses invalid input at the boundary
instead of trusting a caller who may be wiring buttons from data.
Identity is ChoiceId, not PortId. A handle legitimately carries several edges — two replies out
of one outgoing handle are two edges reporting the same port — so keying your buttons by port id
collapses them.
IsTakeable is the one rule, and it is already applied: enabled, and pointing somewhere. Do not
compose your own from the parts.
Why an option is locked
ResolvedChoice.BlockedBy is a list of ChoiceBlock, empty when the option is takeable. Each
entry says which mechanism refused it:
| Member | What it holds |
|---|---|
Kind | a gate — the exit’s own condition — or a requirement row on the node this choice leads to |
Expression | the source. Diagnostics only; never show it to a player, it is developer-facing and untranslatable |
TagId / TagLabel | the author’s category for this check, already looked up. This is what you draw as [STRENGTH], and what you key your icon or colour off |
Reason | the author’s own sentence, when they wrote one. Prefer it over anything you compose. 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 | which node carries the requirement |
Terms | what the player needs beside what they hold — one entry per failed check |
A choice’s own label comes from the ResolvedField values its destination lends it, which is why
the sample reads Content rather than deriving which fields supply the text.
Drawing an option as a tile
An option from a repeater row whose block carries a picture — a media field or a character —
arrives with ResolvedChoice.PictureKey set: the key of that field on the step’s node, in the same
key space as the content. It is a key, not a picture. Read the field with
step.FieldByKey(choice.PictureKey) and take its Media() or Entity(), exactly as you would for
any field of the step. It is null on every other option, and it never appears in Content, so a
text-only presenter is unaffected.
It is a list because a choice can be refused twice at once: by its own gate, and by a requirement row on its destination.
Where a requirement actually lives
The author writes a requirement once, on the destination — “you need STRENGTH 8 to get in here” — and the runtime surfaces it on every choice that leads there. That is why a check survives an edge being deleted and redrawn, and why it travels with copy and paste.
Two consequences worth knowing:
- A row whose policy is
hidenever reaches you. The option is not inChoicesat all. - An authored jump target bypasses requirements. An override of the graph overrides this too.
Drawing a progress reading
Terms is a list of BlockedTerm, and it is what lets you render 6/8 rather than a bare
“locked”. Each term’s Name is what was checked — the left side of a comparison, or the whole leaf otherwise — and its Values holds the
player’s side beside the wanted side, in the author’s order.
Three details the sample above handles and a naive version would not:
Valuesholds either two entries or none. A bare truth test has no second side.- There can be several terms, because
a && bfails twice. - An empty
Termsis a real answer, not a gap. A gate that is a bare literal, such asfalse, has nothing to name.
Name is the variable that was checked, never the row’s tag. The tag is the badge; the name is the
thing.
Next
One handler per node type — where a node type’s own logic goes.