Pattern: Program-First Architecture
Context
When building LLM-powered game engines, developers face a fundamental choice: should the LLM drive the game logic, or should programmatic code drive logic while the LLM provides narration?
Use this pattern when:
- Building games that require consistent rules and state
- Working with smaller LLMs (7B-9B parameters) that hallucinate
- Need deterministic outcomes for player actions
- Want to support save/load functionality reliably
Forces
Competing concerns that led to this design:
-
Reliability vs Creativity
- LLMs are creative but inconsistent
- Programmatic code is reliable but rigid
-
Cost vs Quality
- Larger LLMs (GPT-4) follow rules better but are expensive
- Smaller LLMs are cheap but unreliable with decision-making
-
Development Speed vs Control
- Letting LLM handle everything is faster to prototype
- Programmatic control requires more upfront architecture
-
Player Freedom vs Game Balance
- LLMs might allow impossible actions
- Programmatic validation maintains game balance
Solution
Structure
flowchart TD A[User Input] -->|Natural Language| B[Input Parser] B -->|Structured Intent| C[Game Logic Engine] C -->|State Changes| D[State Manager] D -->|Updated State| E[NDL Generator] E -->|Structured Events| F[LLM Narrator] F -->|Natural Language| G[Output to User] D -.->|Current State| E D -.->|Persists| H[(Database)] style C fill:#4a90e2 style D fill:#4a90e2 style F fill:#e27d60
Key Components:
-
Input Parser (Programmatic)
- Extracts player intent from natural language
- Can use lightweight LLM or regex/NLP
- Output: Structured action data
-
Game Logic Engine (Programmatic)
- Evaluates action validity
- Applies game rules
- Rolls dice, checks stats
- Determines outcomes
- Updates state
-
State Manager (Programmatic)
- Maintains authoritative game state
- Persists to database
- Provides state snapshots
-
NDL Generator (Programmatic)
- Converts state changes to structured event descriptions
- Prepares context for LLM
-
LLM Narrator (LLM)
- Receives predetermined outcomes
- Generates natural language narrative
- No decision-making power
Implementation
Python Example
from typing import Dict, Any, List
from dataclasses import dataclass
@dataclass
class GameState:
"""Authoritative game state"""
player_hp: int
player_location: str
inventory: List[str]
npcs_present: List[str]
time: int
@dataclass
class Action:
"""Parsed player action"""
verb: str
target: str
method: str = ""
@dataclass
class Outcome:
"""Result of game logic processing"""
success: bool
state_changes: Dict[str, Any]
ndl_description: str
class GameLogicEngine:
"""Deterministic game rules"""
def __init__(self, state: GameState):
self.state = state
def process_action(self, action: Action) -> Outcome:
"""Apply game rules to determine outcome"""
if action.verb == "attack":
return self._process_attack(action)
elif action.verb == "move":
return self._process_movement(action)
elif action.verb == "take":
return self._process_take_item(action)
else:
return Outcome(
success=False,
state_changes={},
ndl_description="action(unknown)"
)
def _process_attack(self, action: Action) -> Outcome:
"""Deterministic combat resolution"""
import random
# Roll for hit
attack_roll = random.randint(1, 20)
target_defense = 12 # Would come from NPC stats
if attack_roll >= target_defense:
damage = random.randint(1, 8)
return Outcome(
success=True,
state_changes={"target_hp": -damage},
ndl_description=f'do($player, "attack")~"{action.method}"->target(${action.target})->roll("hit", result="success")->damage({damage})'
)
else:
return Outcome(
success=False,
state_changes={},
ndl_description=f'do($player, "attack")~"{action.method}"->target(${action.target})->roll("hit", result="failure")'
)
def _process_movement(self, action: Action) -> Outcome:
"""Validate and execute movement"""
valid_exits = self._get_exits(self.state.player_location)
if action.target in valid_exits:
return Outcome(
success=True,
state_changes={"player_location": action.target},
ndl_description=f'do($player, "move")~"{action.method}"->destination(${action.target})->result("success")'
)
else:
return Outcome(
success=False,
state_changes={},
ndl_description=f'do($player, "attempt_move")->destination(${action.target})->result("blocked")'
)
def _process_take_item(self, action: Action) -> Outcome:
"""Validate and execute item pickup"""
# Check if item exists in location
location_items = self._get_location_items(self.state.player_location)
if action.target in location_items:
return Outcome(
success=True,
state_changes={
"inventory": self.state.inventory + [action.target],
"location_items": [i for i in location_items if i != action.target]
},
ndl_description=f'do($player, "take")~"carefully"->item(${action.target})->result("success")'
)
else:
return Outcome(
success=False,
state_changes={},
ndl_description=f'do($player, "search")~"thoroughly"->result("not_found", item=${action.target})'
)
def _get_exits(self, location: str) -> List[str]:
"""Get valid exits from location"""
# Would query from database/scene graph
return ["north", "south", "east", "west"]
def _get_location_items(self, location: str) -> List[str]:
"""Get items in location"""
# Would query from database
return ["sword", "gold_coin", "health_potion"]
class LLMNarrator:
"""LLM wrapper for narration only"""
def __init__(self, api_client):
self.client = api_client
def narrate_outcome(self, outcome: Outcome, context: GameState) -> str:
"""Convert NDL to natural language"""
prompt = f"""You are a game narrator. Convert the following game events into natural prose.
Setting: {context.player_location}
NPCs Present: {', '.join(context.npcs_present)}
Time: {context.time}
Events: {outcome.ndl_description}
Write a 2-3 sentence natural description of these events:"""
response = self.client.complete(prompt, temperature=0.7)
return response
class GameEngine:
"""Orchestrates program-first architecture"""
def __init__(self, initial_state: GameState, llm_client):
self.state = initial_state
self.logic = GameLogicEngine(initial_state)
self.narrator = LLMNarrator(llm_client)
def handle_player_input(self, user_input: str) -> str:
"""Complete program-first pipeline"""
# 1. Parse input (could use lightweight LLM or NLP)
action = self._parse_input(user_input)
# 2. Apply game logic (deterministic)
outcome = self.logic.process_action(action)
# 3. Update state (programmatic)
if outcome.success:
self._apply_state_changes(outcome.state_changes)
# 4. Generate narrative (LLM)
narrative = self.narrator.narrate_outcome(outcome, self.state)
return narrative
def _parse_input(self, user_input: str) -> Action:
"""Extract action from natural language"""
# Simplified parser - could use LLM with low temp
words = user_input.lower().split()
verb_map = {
"attack": "attack",
"hit": "attack",
"strike": "attack",
"go": "move",
"move": "move",
"walk": "move",
"take": "take",
"grab": "take",
"pickup": "take"
}
verb = None
for word in words:
if word in verb_map:
verb = verb_map[word]
break
# Extract target (simplified)
target = words[-1] if len(words) > 1 else ""
return Action(verb=verb or "unknown", target=target)
def _apply_state_changes(self, changes: Dict[str, Any]):
"""Update game state"""
for key, value in changes.items():
if hasattr(self.state, key):
if isinstance(value, list):
setattr(self.state, key, value)
else:
current = getattr(self.state, key)
setattr(self.state, key, current + value)
# Usage Example
if __name__ == "__main__":
# Initialize
initial_state = GameState(
player_hp=100,
player_location="tavern",
inventory=[],
npcs_present=["bartender", "guard"],
time=0
)
# Mock LLM client
class MockLLMClient:
def complete(self, prompt, temperature):
return "You swing your sword at the guard, striking true. He staggers back, wounded."
engine = GameEngine(initial_state, MockLLMClient())
# Process player action
response = engine.handle_player_input("I attack the guard with my sword")
print(response)Consequences
Benefits
- Reliability: Game logic is deterministic and testable
- Model Flexibility: Works on small models (8B-9B)
- Cost Efficiency: Expensive LLMs only for narration
- Consistency: Same inputs always produce same state changes
- Debuggability: Clear separation makes bugs easier to find
- Save/Load: State is always consistent and serializable
Liabilities
- More Upfront Work: Requires building game logic engine
- Less Flexible: Can’t easily add new mechanics via prompting
- Two Systems: Must maintain both programmatic logic and LLM narration
- Parsing Challenges: Converting natural language to structured actions
Alternatives
LLM-First Architecture (rejected by community):
User Input → LLM → Parse LLM output for state changes → Update state
Problems:
- LLMs hallucinate game state
- Unreliable state extraction
- Expensive (requires large models)
- Can’t guarantee game rules are followed
Related Patterns
- NDL Bridge Pattern - How to structure events for LLM
- Three-Tier Persistence - Managing the state layer
- Constraint-Based Prompting - Guiding LLM narration
Source
Original Discussions:
- January 2024 architecture debates
- Contributors: User-vali98, User-50h100a, User-irovos
Key Quotes:
“If you need chatgpt to make it work you’ve already lost” - 50h100a
“Turns out that when you take away decision making from the LLM it behaves much better.” - veritasr
“Just use arbitrary xml tags” → “then do so, man” (the recurring “just” problem)
Referenced in:
Implementation in ChatBotRPG
Status: ✅ EXACT MATCH - This pattern is implemented exactly as described in Discord
Source Files:
src/rules/apply_rules.py- Rule evaluation enginesrc/core/utils.py- State managementsrc/core/process_keywords.py- Keyword logicsrc/core/character_inference.py- Character turn system
Production Example: Character Turn Processing
File: src/core/character_inference.py (lines 268-274)
system_msg_base_intro = (
"You are in a third-person text RPG. "
"You are responsible for writing ONLY the actions and dialogue of your assigned character, "
"as if you are a narrator describing them. "
"You must ALWAYS write in third person (using the character's name or 'he/she/they'), "
"NEVER in first or second person. "
"Write one single open-ended response (do NOT describe the OUTCOME of actions)."
)Key Observation: The LLM is explicitly instructed to NOT decide outcomes - only narrate predetermined actions.
Production Example: Rules System
File: src/rules/apply_rules.py
ChatBotRPG implements a JSON-based rules engine where Python evaluates conditions and triggers actions:
# Rule conditions evaluated in Python, NOT by LLM
{
"id": "midnight_bell",
"trigger": "after_receive",
"start_conditions": [
{"type": "Game Time", "operator": "==", "value": "00:00"} # Python checks this
],
"actions": [
{"type": "Inject Prompt", "content": "You hear church bells..."} # LLM only narrates
]
}Why This Works:
- Game state updates happen in Python (save/load, variables, locations)
- Rule conditions evaluated deterministically
- LLM only generates narrative text (character speech, descriptions)
- LLM never makes gameplay decisions (movement, combat, inventory)
Performance Metrics
From: Discord Claims Validation Report
- Validation Status: 89% accuracy across all architectural patterns
- Implementation Quality: Production-ready, well-tested
- Pattern Adoption: 16/18 Discord patterns found in code
Related Implementation Files
- Pattern-to-Code Mapping - Full mapping of all 18 patterns
- Extracted Prompts Index - All 15 production prompts
Tags
architecture pattern program-first llm game-engine deterministic chatbotrpg-validated