⚠️ Largely superseded. The Token/Singleton architectural pattern described here is now covered authoritatively in
engine/src/tangl/core/CORE_DESIGN.md(Token and Singleton sections). Author-facing examples in this doc may still have reference value.
Singleton Asset: Type/Token Pattern¶
The Pattern¶
Singleton = Immutable Type Definition (Platonic Ideal)
Shared across all instances
Centrally defined, globally consistent
Changes propagate to all tokens
Token = Mutable Token (Individual Instance)
Wraps the singleton type
Adds instance-specific state
Delegates type queries to singleton
# TYPE (Singleton)
class WearableAsset(Singleton):
"""The concept of 'iron_sword' - shared definition."""
desc: str # Type property
value: int = 1 # Type property
material: str = "iron" # Type property
# Instance properties marked with json_schema_extra
times_worn: int = Field(
0,
json_schema_extra={"is_instance_var": True}
)
# TOKEN (Graph Node wrapping Singleton)
Wearable = Token[WearableAsset]
# Usage
iron_sword_type = WearableAsset.get_instance('iron_sword')
# → Singleton, exists once globally
my_sword = Wearable('iron_sword', graph=my_graph)
# → Graph node wrapping the singleton
# → Has its own times_worn counter
# → Delegates desc, value, material to singleton
Why This is Genius¶
Problem 1: Consistency¶
Without this pattern:
# Create 100 swords in different inventories
for i in range(100):
sword = Item(name="Iron Sword", damage=10, material="iron")
# Oh no, swords should be silver now
# → Have to find and update 100 instances 😱
With this pattern:
# Define type once
iron_sword = WearableAsset(label='iron_sword', material='iron', damage=10)
# Create 100 tokens
for i in range(100):
sword = Wearable('iron_sword', graph=graph)
# All delegate to same type
# Update type definition
iron_sword.material = 'silver' # Can't actually do this (immutable)
# BUT: reload the singleton from updated config
WearableAsset.load_instances('updated_weapons.yaml')
# → All 100 tokens now reference updated type
Problem 2: Memory¶
Without this pattern:
# 100 swords × 200 bytes each = 20KB
for i in range(100):
sword = Item(
name="Iron Sword",
desc="A well-crafted iron blade with leather grip...",
damage=10,
durability=100,
# ... lots of shared data
)
With this pattern:
# 1 singleton × 200 bytes + 100 nodes × 24 bytes = ~2.6KB
iron_sword_type = WearableAsset(...) # 200 bytes, once
for i in range(100):
sword = Wearable('iron_sword') # 24 bytes (just wraps UUID + state)
The Architecture¶
Layer 1: Singleton Registry (Type System)¶
# Defined in world config or loaded from YAML
class WearableAsset(Singleton):
# TYPE ATTRIBUTES (shared, immutable)
desc: str
value: int
weight: float
icon: str
# INSTANCE ATTRIBUTES (per-token, mutable)
times_worn: int = Field(0, json_schema_extra={"is_instance_var": True})
condition: float = Field(1.0, json_schema_extra={"is_instance_var": True})
# TYPE METHODS (operate on instance vars)
def put_on(self):
"""Called on token, modifies token state."""
self.parent.tags.add(f'wearing:{self.label}')
self.times_worn += 1
def repair(self):
"""Called on token, modifies token state."""
self.condition = 1.0
# Load from YAML
WearableAsset.load_instances('wearables.yaml')
"""
iron_sword:
desc: "A sturdy iron blade"
value: 50
weight: 3.5
icon: "⚔️"
leather_boots:
desc: "Comfortable leather boots"
value: 20
weight: 1.0
icon: "👢"
"""
# Now they exist globally
WearableAsset.get_instance('iron_sword').desc
# → "A sturdy iron blade"
Layer 2: Token Wrapper (Token System)¶
class Token(Node, Generic[T]):
"""
Graph node that wraps a Singleton instance.
Delegation pattern:
- Type attributes → delegated to singleton
- Instance attributes → stored locally
- Methods → executed in context of this node
"""
singleton_label: UniqueLabel # Reference to singleton
instance_vars: dict[str, Any] # Local state
def __init__(self, singleton_label: str, graph: Graph, **kwargs):
super().__init__(graph=graph, **kwargs)
self.singleton_label = singleton_label
self.instance_vars = {}
# Initialize instance vars from singleton defaults
singleton_cls = self._get_singleton_class()
singleton = singleton_cls.get_instance(singleton_label)
for field_name, field in singleton.model_fields.items():
if field.json_schema_extra and \
field.json_schema_extra.get('is_instance_var'):
# Copy default to instance
self.instance_vars[field_name] = getattr(singleton, field_name)
@property
def singleton(self) -> T:
"""Get the wrapped singleton instance."""
singleton_cls = self._get_singleton_class()
return singleton_cls.get_instance(self.singleton_label)
def __getattr__(self, name: str) -> Any:
"""
Delegation:
1. Check instance vars first (mutable state)
2. Delegate to singleton (type attributes)
"""
if name in self.instance_vars:
return self.instance_vars[name]
return getattr(self.singleton, name)
def __setattr__(self, name: str, value: Any):
"""Route writes to instance vars or parent."""
if name in ['singleton_label', 'instance_vars', 'graph', 'uid', 'label']:
super().__setattr__(name, value)
elif hasattr(self, 'instance_vars') and name in self.instance_vars:
self.instance_vars[name] = value
else:
super().__setattr__(name, value)
# Type alias for clarity
Wearable = Token[WearableAsset]
Layer 3: Usage in Story¶
# In story script or programmatically
my_sword = Wearable('iron_sword', graph=story_graph)
# Type queries (delegated to singleton)
my_sword.desc # → "A sturdy iron blade" (from singleton)
my_sword.value # → 50 (from singleton)
my_sword.icon # → "⚔️" (from singleton)
# Instance state (local to this token)
my_sword.times_worn # → 0 (from instance_vars)
my_sword.condition # → 1.0 (from instance_vars)
# Modify instance
my_sword.put_on()
my_sword.times_worn # → 1 (instance_vars updated)
# Type unchanged
WearableAsset.get_instance('iron_sword').times_worn
# → Still 0 (singleton default)
# Create another token
your_sword = Wearable('iron_sword', graph=story_graph)
your_sword.times_worn # → 0 (fresh instance)
my_sword.times_worn # → 1 (independent instance)
Asset Manager Role¶
class AssetManager:
"""
Manages singleton asset types for a world.
Responsibilities:
1. Load asset type definitions from config
2. Register singleton classes
3. Provide factory methods for creating tokens
"""
def __init__(self):
self.asset_classes: dict[str, Type[Singleton]] = {}
def register_asset_class(self, name: str, cls: Type[Singleton]):
"""Register a singleton asset type class."""
self.asset_classes[name] = cls
def load_assets_from_file(self, asset_type: str, filepath: Path):
"""
Load asset instances from YAML.
Example:
asset_manager.load_assets_from_file('wearables', 'wearables.yaml')
"""
cls = self.asset_classes.get(asset_type)
if not cls:
raise ValueError(f"Unknown asset type: {asset_type}")
cls.load_instances(filepath)
def create_token(
self,
asset_type: str,
singleton_label: str,
graph: Graph
) -> Token:
"""
Create a graph node token for an asset.
Example:
token = asset_manager.create_token('wearables', 'iron_sword', graph)
"""
cls = self.asset_classes[asset_type]
wrapper_cls = Token[cls]
return wrapper_cls(singleton_label, graph=graph)
def get_asset_type(self, asset_type: str, label: str) -> Singleton:
"""Get the singleton type definition."""
cls = self.asset_classes[asset_type]
return cls.get_instance(label)
# World setup
asset_manager = AssetManager()
asset_manager.register_asset_class('wearables', WearableAsset)
asset_manager.load_assets_from_file('wearables', 'wearables.yaml')
# Now available globally
asset_manager.get_asset_type('wearables', 'iron_sword').desc
# → "A sturdy iron blade"
Resource Manager (Files/Media)¶
Separate concern: managing on-disk assets.
class ResourceManager:
"""
Manages file-based resources (images, audio, etc).
NOT about game assets - this is about media files.
"""
def __init__(self, resource_path: Path):
self.resource_path = resource_path
self.icon_cache: dict[str, str] = {}
self.image_cache: dict[str, bytes] = {}
def get_icon_url(self, icon_name: str) -> str:
"""Get URL for an icon."""
return f"/assets/icons/{icon_name}.png"
def get_image_data(self, image_name: str) -> bytes:
"""Load image file."""
if image_name not in self.image_cache:
path = self.resource_path / 'images' / f'{image_name}.png'
self.image_cache[image_name] = path.read_bytes()
return self.image_cache[image_name]
def get_audio_url(self, audio_name: str) -> str:
"""Get URL for audio file."""
return f"/assets/audio/{audio_name}.mp3"
# World setup includes both
world = World(
label='my_world',
script_manager=script_manager,
asset_manager=asset_manager, # Singleton game assets
resource_manager=resource_manager # File-based media
)
Script Format for Assets¶
YAML Definition¶
# wearables.yaml (loaded by AssetManager)
iron_sword:
desc: "A sturdy iron blade"
value: 50
weight: 3.5
icon: "sword_iron"
damage: 15
durability: 100
leather_boots:
desc: "Comfortable leather boots"
value: 20
weight: 1.0
icon: "boots_leather"
armor: 2
health_potion:
desc: "Restores 50 health"
value: 15
weight: 0.2
icon: "potion_red"
healing: 50
Story Script Reference¶
# story.yaml
label: my_story
metadata:
title: "Adventure"
author: "Me"
# Load asset types (tells world to load wearables.yaml)
assets:
- asset_type: wearables
source: wearables.yaml
scenes:
treasure_room:
blocks:
entrance:
content: "You find a chest!"
# Create token in player inventory
effects:
- "player.acquire_asset('wearables', 'iron_sword')"
actions:
- text: "Continue"
successor: hallway
Programmatic Token Creation¶
# In block effect handler
def acquire_asset(player: Actor, asset_type: str, asset_label: str):
"""Give player a token of an asset."""
# Create token
token = player.graph.world.asset_manager.create_token(
asset_type,
asset_label,
graph=player.graph
)
# Add to player's wallet/inventory
player.inventory.add(token)
Benefits Recap¶
1. Consistency¶
# Update type definition
WearableAsset.load_instances('updated_wearables.yaml')
# → All existing tokens now reference updated type
# → No need to find and update individual instances
2. Memory Efficiency¶
# 10,000 swords across all stories
# Old way: 10,000 × 200 bytes = 2MB
# New way: 1 × 200 bytes + 10,000 × 24 bytes = ~240KB
3. Centralized Logic¶
# Define behavior once on singleton
class WearableAsset(Singleton):
def put_on(self):
self.times_worn += 1
# Complex logic here, shared by all tokens
4. Query Type Properties¶
# Get all swords with damage > 20
powerful_swords = [
label for label, asset in WearableAsset.all_instances()
if asset.damage > 20
]
# Create tokens for these
for label in powerful_swords:
token = Wearable(label, graph=graph)
Implementation Questions¶
Q1: Where do SingletonNodes live?¶
Option A: In graph, linked to owner
my_sword = Wearable('iron_sword', graph=story_graph)
player.inventory.add(my_sword)
# sword.uid in graph.nodes
# Edge from player → sword
Option B: In registry, referenced by owner
my_sword = Wearable('iron_sword') # No graph
player.inventory[my_sword.uid] = my_sword
# Not in graph.nodes, just in player's dict
Recommendation: Option A (in graph) for consistency with other nodes.
Q2: How to handle countable assets (potions)?¶
class CountableAsset(Singleton):
"""Asset that can be stacked."""
stack_size: int = 99
# In player inventory
class Wallet:
counts: dict[UUID, int] # asset_type_uid → count
def add(self, asset_type: Singleton, count: int = 1):
self.counts[asset_type.uid] = \
self.counts.get(asset_type.uid, 0) + count
def has(self, asset_type: Singleton, count: int = 1) -> bool:
return self.counts.get(asset_type.uid, 0) >= count
Q3: What about durability/condition?¶
Instance var approach (your current):
class WearableAsset(Singleton):
condition: float = Field(1.0, json_schema_extra={"is_instance_var": True})
my_sword = Wearable('iron_sword')
my_sword.condition = 0.5 # This sword is damaged
Alternative: Locals approach:
my_sword = Wearable('iron_sword')
my_sword.locals['condition'] = 0.5
Recommendation: Instance vars (explicit schema, type-safe).
World Architecture Update¶
class World(Singleton):
def __init__(
self,
label: str,
script_manager: ScriptManager,
domain_manager: DomainManager,
asset_manager: AssetManager, # NEW
resource_manager: ResourceManager # NEW
):
super().__init__(label=label)
self.script_manager = script_manager
self.domain_manager = domain_manager
self.asset_manager = asset_manager
self.resource_manager = resource_manager
# Load assets from script
for asset_config in script_manager.get_assets():
self.asset_manager.load_assets_from_file(
asset_config['asset_type'],
asset_config['source']
)
Testing Strategy¶
def test_singleton_asset_pattern():
"""Test type/token separation."""
# Setup asset type
class TestAsset(Singleton):
desc: str
value: int
uses: int = Field(0, json_schema_extra={"is_instance_var": True})
TestAsset(label='sword', desc='A sword', value=50)
# Create tokens
graph = Graph(label='test')
token1 = Token[TestAsset]('sword', graph=graph)
token2 = Token[TestAsset]('sword', graph=graph)
# Type properties are shared
assert token1.desc == token2.desc == "A sword"
assert token1.value == token2.value == 50
# Instance properties are independent
token1.uses += 1
assert token1.uses == 1
assert token2.uses == 0
# Singleton unchanged
assert TestAsset.get_instance('sword').uses == 0
def test_asset_manager_integration():
"""Test asset manager in world."""
asset_manager = AssetManager()
asset_manager.register_asset_class('weapons', WeaponAsset)
asset_manager.load_assets_from_file('weapons', 'test_weapons.yaml')
# Create world
world = World(
label='test_world',
script_manager=script_manager,
asset_manager=asset_manager
)
# Create story
story = world.create_story('test_story')
# Create token
sword = asset_manager.create_token('weapons', 'iron_sword', story)
assert sword.desc == "A sturdy iron blade"
assert sword.damage == 15
assert sword.times_used == 0
Summary¶
Three Manager Pattern:
ScriptManager: Story templates (scenes, blocks)
AssetManager: Singleton game assets (items, abilities)
ResourceManager: File-based media (images, audio)
Four Manager Pattern (Including Domain):
ScriptManager: Story templates
DomainManager: Custom classes/handlers
AssetManager: Singleton game assets
ResourceManager: File-based media
Type/Token Pattern:
Singleton: Immutable type definition, globally shared
Token: Mutable token, wraps singleton, independent state
Benefits: Consistency, memory efficiency, centralized updates
This pattern explains your singleton obsession perfectly. It’s not just for uniqueness - it’s for shared type identity with independent instance state.