Sandbox Hubs as Fanout Operators¶
Status: 🟡 DESIGN NOTE — future direction, v38 vocabulary update
Prior art: sandbox_v30.py, sandbox.py, sandbox_handler.py, schedule.py
Relevant layers: story.episode, vm.provision, story.fabula.materializer
Core Insight¶
The v3.0 “sandbox” system was an independent subsystem with its own traversal handler, schedule evaluator, event dispatcher, and grid cursor. In v38, all of its moving parts decompose into existing primitives:
SandboxNode is a MenuBlock with multiple fanouts
SandboxEvent is a tagged Block (or Scene) with availability conditions
MobileActor is an Actor whose current location is a scheduled namespace value
SandboxLocation is a Location with conditional admission
Schedule is a namespace provider that publishes world-time into the condition evaluator
Connections are Action edges between hub MenuBlocks (possibly with travel-cost effects)
Forced events are redirects with conditions (already supported)
Selectable events are Fanout-discovered menu items (the MenuBlock pattern)
A sandbox hub is not a new node type. It is a MenuBlock with several concurrent fanouts operating over different provider scopes.
The Hub Pattern¶
A sandbox location (“town square”, “tavern”, “crossroads”) is a re-entrant hub that dynamically composes its available choices from multiple sources each time the cursor arrives. In v38, this is authored as:
blocks:
- label: town_square
kind: MenuBlock
content: >
The town square bustles with activity.
A fountain burbles in the center.
menu_items:
# Fanout 1: local activities at this location
- has_tags: [activity, town_square]
# Fanout 2: present NPCs (actors whose schedule puts them here)
- has_tags: [conversation]
has_ancestor_tags: [npc_present]
actions:
# Static connections to other hubs
- text: "Go to the market"
successor: market
effects: ["world.advance_time(1)"]
- text: "Head to the tavern"
successor: tavern
effects: ["world.advance_time(1)"]
Each menu_items entry becomes a Fanout at materialization time. When
the cursor enters the hub, the resolver gathers all eligible providers,
the provisioner projects them as dynamic Action edges, and the journal
handler renders them alongside the static choices. This is exactly what
test_menu_fanout.py validates.
Discovery Channels¶
The v3.0 sandbox mixed three discovery mechanisms into one events property.
In v38, each is a separate fanout with its own selector scope:
1. Location Activities (place-scoped fanout)¶
Blocks tagged with the hub’s location tag and activity. These are
the “things you can do here” - visit a shop, explore ruins, rest.
# Activity block - discovered by the town_square hub
- tags: [activity, town_square]
action_name: "Visit the blacksmith"
content: The forge glows with heat...
conditions: ["blacksmith_open"]
In v3.0 this was HasSandboxEvents._include_selectable_events. In v38
it’s a standard fanout with has_tags: [activity, town_square].
2. Present NPCs (schedule-gated fanout)¶
Actors move between locations on a schedule. Their conversation/event blocks are only discoverable when the actor is “present” at the current hub. In v38, this is a two-part mechanism:
Part A - Actor presence is a local namespace publication. The actor’s current location can be derived from its own stored schedule state and then gathered into scoped runtime views:
from tangl.core import contribute_ns
@contribute_ns
def provide_location_symbols(self):
return {"current_location": self.locals.get("current_location")}
Part B - Conversation fanout uses the presence value as a condition.
The hub’s fanout discovers blocks tagged conversation, and the block’s
own conditions gate on the actor being present:
- tags: [conversation]
action_name: "Talk to the merchant"
conditions: ["merchant.current_location == 'town_square'"]
content: The merchant adjusts her spectacles...
In v3.0 this was SandboxRole with sb_schedule and
_include_inferred_role_req. In v38, the schedule is just data in
locals, the presence check is a condition expression, and discovery
is a standard fanout.
3. Forced Events (redirect with conditions)¶
Time-gated or state-gated events that preempt player choice. In v3.0
this was SandboxEventHandler.get_redirect_event with
event_activation == "forced". In v38, these are authored as
redirects with conditions:
- label: town_square
redirects:
- successor: festival_scene
conditions: ["world_time.season == 'summer' and world_time.day == 1"]
No fanout needed - this is static compilation. The redirect fires during the ENTER phase if its conditions are met.
World Time as Namespace¶
The v3.0 system had WorldTime as a dedicated model with period/day/
month/season/year. In v38, this can be published locally on the story
graph or world and then gathered into scope:
from tangl.core import contribute_ns
@contribute_ns
def provide_world_time_symbols(self):
turn = self.locals.get("world_turn", 0)
return {
"world_time": WorldTime.from_turn(turn),
"world_turn": turn,
}
WorldTime itself can be a simple dataclass or Pydantic model. It
doesn’t need to be part of the graph structure - it’s a derived value
from the world turn counter, which is a ledger-level concern.
The advance_time effect on connection actions increments the turn
counter in the ledger namespace. All schedule evaluations recompute
from the new turn value on the next provision pass.
Connections and Travel¶
In v3.0, SandboxNode.connection_refs were explicit edge lists, and
SandboxGrid inferred connections from grid adjacency. In v38, both
patterns are supported:
Explicit connections are authored as static actions on the hub MenuBlock. Travel time is an effect on the action:
actions:
- text: "Travel to Fort Worth"
successor: fort_worth
effects: ["world.advance_time(4)"]
conditions: ["player.has_vehicle"]
Grid-inferred connections would be a compile-time step in a grid codec or a domain-specific materializer hook. The codec reads the grid map and emits explicit actions with computed travel costs. The compiler sees the same dict shape either way.
Anonymous Activity Blocks¶
Many sandbox activities are one-off interactions that don’t need global labels. These are the anonymous blocks we implemented - unlabelled blocks with tags that the hub’s fanout discovers:
blocks:
- label: town_square
kind: MenuBlock
menu_items:
- has_tags: [activity, town_square]
# Anonymous - discovered by tag, no label needed
- tags: [activity, town_square]
action_name: "Pet the dog"
content: A friendly mutt wags its tail.
- tags: [activity, town_square]
action_name: "Toss a coin in the fountain"
content: The coin glints as it sinks.
conditions: ["player.gold > 0"]
effects: ["player.gold -= 1", "player.luck += 1"]
The compiler assigns synthetic labels (_anon_0, _anon_1). The
fanout finds them by tag. The action_name field provides the menu
choice text via MenuBlock.action_text_for.
Re-entrancy¶
The defining characteristic of a sandbox hub is re-entrancy: the player
visits, makes a choice (talk to NPC, do activity, travel), and returns
to the same hub afterward. In v3.0 this was ad-hoc logic in
SandboxActionScript defaulting successor_ref to "return".
In v38, re-entrancy is a VM-level concept (call stack return) or an authored pattern (activity block’s action points back to the hub):
# Activity that returns to the hub
- tags: [activity, town_square]
action_name: "Rest at the inn"
content: You take a nap...
effects: ["player.health = player.max_health", "world.advance_time(8)"]
actions:
- text: "Wake up"
successor: town_square # explicit return
Or, if the hub declares return_when_done: True on its menu_items,
the VM pushes a return frame and the activity block doesn’t need to
know which hub invoked it. This is the call-stack pattern and it’s
cleaner for activities that multiple hubs might share.
This same pattern is broader than sandbox travel loops. It also covers repeatable activity kernels and game-like interactions:
a tavern hub can call into a reusable “play cards” block and return
a workshop hub can invoke a short crafting loop and return
a game block can project a self-fanout of move edges until a terminal result is reached
The shared design question is whether a revisit means:
returning to the same provider instance,
calling into a provider and then returning to the invoking hub, or
materializing a fresh per-visit instance so replay and journal lineage stay more explicit
For sandbox-oriented hubs, the default should usually be “call and return” or “reuse the same provider” unless a concrete story need justifies per-visit instancing.
What This Replaces¶
v3.0 concept |
v38 equivalent |
|---|---|
|
World or StoryGraph with |
|
|
|
Tagged |
|
|
|
|
|
Namespace provider + condition expressions |
|
Derived dataclass, published locally via |
|
|
|
Grid codec producing standard hub + action topology |
Why This Is Better¶
The old sandbox system was conceptually rich but structurally separate. The v38 fanout interpretation is better because it:
reuses the same provisioning and traversal machinery as everything else
makes sandbox locations authorable in the same block/scene idiom as story hubs
keeps schedule and availability in the condition/namespace layer
lets newly introduced providers appear naturally without hand-editing hub menus
supports “always something interesting where the player wants to go” by gathering only currently eligible events
This is not just a cleanup. It makes sandbox mechanics composable with all the other systems already in the engine.