Choice Visibility
Overview
When a CHOICE block is dispatched, context.options always contains all choices defined in the blueprint — none are pre-filtered. The engine never removes choices from the array.
If visibility filtering is needed (e.g., hiding choices based on game state or previous selections), the engine provides an opt-in tagging system. A condition resolver is installed once, and the engine tags each choice with visible: true | false before the onChoice handler sees it.
Setup
Register a condition resolver on the engine — once, before starting any scene:
engine.onResolveCondition((condition) => {
// Evaluate game-state conditions only.
// choice: conditions are handled internally by the engine.
return gameState.check(condition.key, condition.operator, condition.value);
});engine.OnResolveCondition(cond => {
return GameState.Check(cond.Key, cond.Operator, cond.Value);
});engine.onResolveCondition([](const ConditionTest& cond) {
return gameState.check(cond.key, cond.op, cond.value);
});engine.on_resolve_condition(func(cond):
return game_state.check(cond)
)When installed, the engine evaluates each choice's when before calling onChoice. The same resolver also pre-evaluates condition block groups — see Condition blocks for details.
choice:conditions (referencing previous player selections) are resolved automatically by the engine via its internal choice history — the callback never sees them.- Game-state conditions (everything else) are delegated to the callback.
- Chaining with
&(AND) and|(OR) works correctly across both types.
Filtering in onChoice
In the handler, filter with one line:
engine.onChoice(({ context, next }) => {
// `visible` is undefined when no resolver is installed: unknown, not hidden.
const offered = context.options.filter(o => o.visible !== false);
showOptionsUI(offered, (optionId) => {
context.selectChoice(optionId); // the option id IS the exit port (C1, C2…)
next();
});
});engine.OnChoice(args => {
var offered = args.Context.Options
.Where(o => o.Visible != false).ToList();
ShowChoicesUI(offered, optionId => {
args.Context.SelectChoice(optionId);
args.Next();
});
return null;
});engine.onChoice([](auto*, auto*, auto* ctx, auto next) -> lsde::CleanupFn {
std::vector<const lsde::RuntimeChoiceItem*> offered;
for (const auto& o : ctx->options())
if (!o.visible.has_value() || *o.visible)
offered.push_back(&o);
showOptionsUI(offered, [ctx, next](const std::string& optionId) {
ctx->selectChoice(optionId);
next();
});
return {};
});engine.on_choice(func(args):
var offered = []
for o in args["context"].options:
if o.get("visible") != false:
offered.append(o)
show_options_ui(offered, func(option_id):
args["context"].select_choice(option_id)
args["next"].call()
)
return Callable()
)Why visible !== false and not === true?
When no resolver is installed, visible is undefined. Since undefined !== false evaluates to true, all choices pass — backward compatible by default. When a resolver is installed, choices are tagged true or false explicitly.
visible value | Meaning | !== false |
|---|---|---|
true | Resolver installed, choice passes | true |
false | Resolver installed, choice hidden | false |
undefined | No resolver installed | true |
RuntimeChoiceItem
Every entry of context.options is a RuntimeChoiceItem — the blueprint's Option, plus the visible tag:
interface RuntimeChoiceItem extends Option {
visible?: boolean; // true | false | undefined
}public class RuntimeChoiceItem : Option
{
public bool? Visible { get; set; } // true | false | null
}struct RuntimeChoiceItem : Option {
std::optional<bool> visible; // true | false | nullopt
};# RuntimeChoiceItem is a Dictionary with an extra "visible" key:
# { "id": "C1", "key": "...", "text": {...}, "visible": true/false/absent }Without a resolver, choices are still RuntimeChoiceItem but visible remains undefined/null/nullopt/absent. The Option itself carries id, key, text and when — and its id is the exit port (C1, C2…).
Examples
Standard — show visible choices
engine.onChoice(({ context, next }) => {
const offered = context.options.filter(c => c.visible !== false);
ui.showOptions(visible, (optionId) => {
context.selectChoice(optionId);
next();
});
});engine.OnChoice(args => {
var visible = args.Context.Options
.Where(c => c.Visible != false).ToList();
ShowChoicesUI(visible, optionId => {
args.Context.SelectChoice(optionId);
args.Next();
});
return null;
});engine.onChoice([](auto*, auto*, auto* ctx, auto next) -> CleanupFn {
std::vector<const RuntimeChoiceItem*> visible;
for (const auto& c : ctx->options())
if (!c.visible.has_value() || c.visible.value())
visible.push_back(&c);
showOptionsUI(visible, [ctx, next](const auto& optionId) {
ctx->selectChoice(optionId);
next();
});
return {};
});engine.on_choice(func(args):
var visible = []
for c in args["context"].options:
if c.get("visible") != false:
visible.append(c)
show_options_ui(visible, func(option_id):
args["context"].select_choice(option_id)
args["next"].call()
)
return Callable()
)Timed choice — auto-select on timeout
engine.onChoice(({ block, context, next }) => {
const offered = context.options.filter(c => c.visible !== false);
const timeout = LsdeUtils.getNativeProperties(block)?.timeout;
const resolve = (choice) => {
context.selectChoice(choice.id);
next();
};
// `timeout` is MILLISECONDS in v2 — no × 1000 — and on a CHOICE it is counted from
// the moment the options are readable. It still has to SELECT one: an option id IS the
// exit port, so a choice left without selectChoice() resolves to no link at all.
if (timeout) {
const timer = setTimeout(() => resolve(offered[0]), timeout);
ui.showOptions(offered, (optionId) => {
clearTimeout(timer);
resolve(offered.find(c => c.id === optionId));
});
} else {
ui.showOptions(offered, (optionId) => resolve(offered.find(c => c.id === optionId)));
}
});engine.OnChoice(args => {
var (_, block, context, next) = args;
var visible = context.Options
.Where(c => c.Visible != false).ToList();
var timeout = block.NativeProperties?.Timeout;
void Resolve(RuntimeChoiceItem choice) {
context.SelectChoice(choice.Id);
next();
}
if (timeout.HasValue)
{
// use your engine's timer — cancel on player selection
var timer = ScheduleTimer((float)timeout.Value, () => Resolve(visible[0]));
ShowChoicesUI(visible, optionId => {
timer.Cancel();
Resolve(visible.First(c => c.Id == optionId));
});
}
else
{
ShowChoicesUI(visible, optionId => Resolve(visible.First(c => c.Id == optionId)));
}
return null;
});engine.onChoice([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
std::vector<const RuntimeChoiceItem*> visible;
for (const auto& c : ctx->options())
if (!c.visible.has_value() || c.visible.value())
visible.push_back(&c);
auto timeout = block->props
? block->props->timeout : std::nullopt;
auto resolve = [ctx, next](const std::string& optionId) {
ctx->selectChoice(optionId);
next();
};
if (timeout.has_value()) {
// use your engine's timer — cancel on player selection
auto timer = scheduleDelay(timeout.value(), [&]() { resolve(visible[0]->id); });
showOptionsUI(visible, [resolve, timer](const auto& optionId) {
timer->cancel();
resolve(optionId);
});
} else {
showOptionsUI(visible, resolve);
}
return {};
});engine.on_choice(func(args):
var ctx = args["context"]
var next_fn = args["next"]
var block = args["block"]
var visible = []
for c in ctx.options:
if c.get("visible") != false:
visible.append(c)
var timeout_val = block.get("props", {}).get("timeout", 0)
if timeout_val > 0:
# use your engine's timer — cancel on player selection
var timer = get_tree().create_timer(timeout_val)
timer.timeout.connect(func():
ctx.select_choice(visible[0]["id"])
next_fn.call()
)
show_options_ui(visible, func(option_id):
timer.time_left = 0 # cancel
ctx.select_choice(option_id)
next_fn.call()
)
else:
show_options_ui(visible, func(option_id):
ctx.select_choice(option_id)
next_fn.call()
)
return Callable()
)Hidden choices displayed greyed out
engine.onChoice(({ context, next }) => {
for (const choice of context.options) {
if (choice.visible === false) {
ui.addGreyed(choice); // show but disabled
} else {
ui.addNormal(choice); // selectable
}
}
// wait for player selection...
});engine.OnChoice(args => {
foreach (var choice in args.Context.Options)
{
if (choice.Visible == false)
AddGreyed(choice); // show but disabled
else
AddNormal(choice); // selectable
}
// wait for player selection...
return null;
});engine.onChoice([](auto*, auto*, auto* ctx, auto next) -> CleanupFn {
for (const auto& choice : ctx->options()) {
if (choice.visible.has_value() && !choice.visible.value())
addGreyed(choice); // show but disabled
else
addNormal(choice); // selectable
}
// wait for player selection...
return {};
});engine.on_choice(func(args):
for choice in args["context"].options:
if choice.get("visible") == false:
add_greyed(choice) # show but disabled
else:
add_normal(choice) # selectable
# wait for player selection...
return Callable()
)Tutorial — ignore visibility entirely
tutorial.onChoice(({ context, next }) => {
// force-select the first choice, no filtering
context.selectChoice(context.options[0].id);
next();
});tutorial.OnChoice(args => {
// force-select the first choice, no filtering
args.Context.SelectChoice(args.Context.Options[0].Id);
args.Next();
return null;
});tutorial->onChoice([](auto*, auto*, auto* ctx, auto next) -> CleanupFn {
// force-select the first choice, no filtering
ctx->selectChoice(ctx->options()[0].id);
next();
return {};
});tutorial.on_choice(func(args):
# force-select the first choice, no filtering
args["context"].select_choice(args["context"].options[0]["id"])
args["next"].call()
return Callable()
)Sharing the Evaluator
With onResolveCondition, a single callback handles both choice visibility and condition block pre-evaluation. No more duplicating logic:
// ONE evaluator answers everything about game state: option visibility AND condition cases.
// A test on the reserved `choice` dictionary never reaches it — the engine answers those from the
// history it kept during the scene.
engine.onResolveCondition((test) => game.evaluate(test.dict, test.entry, test.op, test.value));
// onCondition is now optional: the engine already knows which port to take.
// Keep it to log what matched, or to override with a PORT NAME.
engine.onCondition(({ block, context, next }) => {
const matched = context.cases.filter((c) => c.result).map((c) => c.port);
game.log(`${block.id} → ${matched[0] ?? 'default'}`);
next();
});engine.OnResolveCondition(test => Game.Evaluate(test.Dict, test.Entry, test.Op, test.Value));
engine.OnCondition(args => {
var matched = args.Context.Cases
.Where(c => c.Result == true)
.Select(c => c.Port)
.FirstOrDefault() ?? Ports.Default;
Game.Log($"{args.Block.Id} → {matched}");
args.Next();
return null;
});engine.onResolveCondition([&game](const lsde::ConditionTest& test) {
return game.evaluate(test.dict, test.entry, test.op, test.value);
});
engine.onCondition([&game](auto*, auto* block, auto* ctx, auto next) -> lsde::CleanupFn {
for (const auto& c : ctx->cases()) {
if (c.result.value_or(false)) { game.log(block->id + " -> " + c.port); break; }
}
next();
return {};
});engine.on_resolve_condition(func(test: Dictionary) -> bool:
return game.evaluate(test["dict"], test["entry"], test["op"], test["value"]))
engine.on_condition(func(args):
for c in args["context"].cases:
if c["result"]:
game.log("%s -> %s" % [args["block"]["id"], c["port"]])
break
args["next"].call()
return Callable()
)One resolver, two jobs — but not one answer
The same callback feeds option visibility and condition routing, so you write your game-state lookup once.
Inside, the engine keeps them apart on purpose. Routing has to pick a branch, so a question it cannot answer becomes false and the flow takes default. An option has no such obligation: saying false about a question nobody could answer would hide an answer from the player, so visible stays undefined instead.
Why one callback?
Before onResolveCondition, the same gameState.check(...) logic had to be registered in both onResolveCondition and onCondition separately. With the unified resolver, it's one callback — the engine handles both automatically.
Advanced: tagging them yourself
If a global resolver is not desired, LsdeUtils.tagOptionVisibility does the same work on demand. It takes two arguments — the options and the evaluator — and returns the list whole, tagged:
import { LsdeUtils, type ConditionEvaluator } from '@lsde/dialog-engine';
const evaluator: ConditionEvaluator = t => gameState.check(t.dict, t.entry, t.op, t.value);
const offered = LsdeUtils.tagOptionVisibility(block.options, evaluator);var offered = LsdeUtils.TagOptionVisibility(
block.Options,
t => GameState.Check(t.Dict, t.Entry, t.Op, t.Value));lsde::ConditionEvaluatorFn evaluator = [](const lsde::ConditionTest& t) {
return gameState.check(t.dict, t.entry, t.op, t.value);
};
auto offered = lsde::LsdeUtils::TagOptionVisibility(block->options, &evaluator);var offered = LsdeUtils.tag_option_visibility(
block.get("options", []),
func(t): return GameState.check(t["dict"], t["entry"], t["op"], t["value"]))choice: conditions are NOT resolved for you here
The shortcut that handled them automatically does not exist: with the engine out of the loop, a test on the reserved choice dictionary reaches your evaluator. Send it back to the scene, which keeps the history:
const evaluator: ConditionEvaluator = t =>
LsdeUtils.isChoiceCondition(t) ? scene.evaluateCondition(t)
: gameState.check(t.dict, t.entry, t.op, t.value);tagOptionVisibility replaces the v1 filterVisibleChoices, which shortened the list and took away the ability to show a locked answer.
