From 9ba45337ca0e6517f33baa170a8d8f67f3f9bdbc Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:59:29 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 12 + README.md | 3 + plugin.lock.json | 85 + skills/using-simulation-tactics/SKILL.md | 974 ++++++ .../ai-and-agent-simulation.md | 2847 +++++++++++++++++ .../crowd-simulation.md | 2637 +++++++++++++++ .../debugging-simulation-chaos.md | 2262 +++++++++++++ .../economic-simulation-patterns.md | 2152 +++++++++++++ .../ecosystem-simulation.md | 1912 +++++++++++ .../performance-optimization-for-sims.md | 2573 +++++++++++++++ .../physics-simulation-patterns.md | 2398 ++++++++++++++ .../simulation-vs-faking.md | 2815 ++++++++++++++++ .../traffic-and-pathfinding.md | 1320 ++++++++ .../weather-and-time.md | 1566 +++++++++ 14 files changed, 23556 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 plugin.lock.json create mode 100644 skills/using-simulation-tactics/SKILL.md create mode 100644 skills/using-simulation-tactics/ai-and-agent-simulation.md create mode 100644 skills/using-simulation-tactics/crowd-simulation.md create mode 100644 skills/using-simulation-tactics/debugging-simulation-chaos.md create mode 100644 skills/using-simulation-tactics/economic-simulation-patterns.md create mode 100644 skills/using-simulation-tactics/ecosystem-simulation.md create mode 100644 skills/using-simulation-tactics/performance-optimization-for-sims.md create mode 100644 skills/using-simulation-tactics/physics-simulation-patterns.md create mode 100644 skills/using-simulation-tactics/simulation-vs-faking.md create mode 100644 skills/using-simulation-tactics/traffic-and-pathfinding.md create mode 100644 skills/using-simulation-tactics/weather-and-time.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..c6bfc53 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "bravos-simulation-tactics", + "description": "Game simulation implementation patterns and tactics - 11 skills", + "version": "1.0.1", + "author": { + "name": "tachyon-beep", + "url": "https://github.com/tachyon-beep" + }, + "skills": [ + "./skills" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa69862 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# bravos-simulation-tactics + +Game simulation implementation patterns and tactics - 11 skills diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..988ff78 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,85 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:tachyon-beep/skillpacks:plugins/bravos-simulation-tactics", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "487e65ddaea034243fc00e7e5bef074f2f793ec1", + "treeHash": "641b001d3cef015209679b48ec66da887aa1a255968d9d5497e6e1a818a3dcd8", + "generatedAt": "2025-11-28T10:28:31.958373Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "bravos-simulation-tactics", + "description": "Game simulation implementation patterns and tactics - 11 skills", + "version": "1.0.1" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "d78f844e54cd70f619377257bb2092750153fce7d558a99a81806802b0dca4a5" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "f676330e95873e1a972bed093ddca83827f81277ce8eb9aead5bd36ab5a98c46" + }, + { + "path": "skills/using-simulation-tactics/crowd-simulation.md", + "sha256": "53e8a9d79291693498a1dc496bd64be0a3977c818750044ccb5ae62ec2a3bdc9" + }, + { + "path": "skills/using-simulation-tactics/weather-and-time.md", + "sha256": "d109e7bdfb33c8dfc5e5665b01df5edabd4a572381ad16341af7a0985256b3c7" + }, + { + "path": "skills/using-simulation-tactics/ecosystem-simulation.md", + "sha256": "28fd51aa03318cc14d538f7d6f22ad9222255ce24cc1a0bf0c92a698ae60fdd4" + }, + { + "path": "skills/using-simulation-tactics/performance-optimization-for-sims.md", + "sha256": "b6469c6664a9342a6a00807ec0da2e8dc1320e15671d7f5607382932e6903169" + }, + { + "path": "skills/using-simulation-tactics/traffic-and-pathfinding.md", + "sha256": "a211afd27f461fb46b12e3e8d10731579f9da48dafedf535e524e673181935ca" + }, + { + "path": "skills/using-simulation-tactics/economic-simulation-patterns.md", + "sha256": "04b42a54494d33b7291db61941bcff799c9c760698c364696b712260131fdca8" + }, + { + "path": "skills/using-simulation-tactics/simulation-vs-faking.md", + "sha256": "f4376643ad25d7ec3fc1b5071de8152408fa55f7ef833cff683b4c4d8bf64b4d" + }, + { + "path": "skills/using-simulation-tactics/SKILL.md", + "sha256": "fd91c640a59df94b52b74ac32a66ef716976684463e2fa02947a509de9428b7e" + }, + { + "path": "skills/using-simulation-tactics/ai-and-agent-simulation.md", + "sha256": "8c8577961f9a8077c2f697648285a526acbb99bcff0ed9ec02d6f5986009b40e" + }, + { + "path": "skills/using-simulation-tactics/debugging-simulation-chaos.md", + "sha256": "29979e29089d4426b3f9307503ed57a6d385046c75c6f393f73469cc171d7860" + }, + { + "path": "skills/using-simulation-tactics/physics-simulation-patterns.md", + "sha256": "9c6e8f0dc3e633d2bd3e64cb6a70585c777ce446bab0777bf395829866a314d4" + } + ], + "dirSha256": "641b001d3cef015209679b48ec66da887aa1a255968d9d5497e6e1a818a3dcd8" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/using-simulation-tactics/SKILL.md b/skills/using-simulation-tactics/SKILL.md new file mode 100644 index 0000000..d66c6cb --- /dev/null +++ b/skills/using-simulation-tactics/SKILL.md @@ -0,0 +1,974 @@ +--- +name: using-simulation-tactics +description: Router skill - analyze requirements and direct to appropriate tactics +mode: true +--- + +# Using Simulation Tactics: The Router Meta-Skill + +## Description + +This is the PRIMARY ROUTER META-SKILL for the simulation-tactics skillpack. It teaches you how to: + +1. **Analyze simulation requirements** - Understand what the user actually needs +2. **Route to appropriate skills** - Determine which of the 10 core skills apply +3. **Apply skills in correct order** - Use the optimal workflow for the situation +4. **Combine multiple skills** - Handle complex scenarios requiring several simulation types + +This skill does NOT teach simulation implementation details. It teaches DECISION MAKING: which skill to use, when, and why. + +## When to Use This Meta-Skill + +Use this meta-skill when: +- Starting ANY simulation-related game development task +- User asks about simulation but unclear which type +- Facing complex scenarios requiring multiple simulation types +- Need to determine implementation order for multi-system games +- Debugging simulation issues and unclear where to start +- Planning architecture for simulation-heavy games + +## The 10 Core Skills + +Before routing, understand what each skill provides: + +### 1. simulation-vs-faking (FOUNDATIONAL) +**What it teaches**: The fundamental trade-off between full simulation and approximation/faking +**When to route**: ALWAYS FIRST - determines if you even need simulation +**Key question**: "Do I simulate this, fake it, or use a hybrid approach?" + +### 2. physics-simulation-patterns +**What it teaches**: Rigid bodies, vehicles, cloth, fluids, integration methods +**When to route**: Need realistic physics for vehicles, ragdolls, destructibles, or fluid dynamics +**Key question**: "Does this need real-time physics simulation?" + +### 3. ai-and-agent-simulation +**What it teaches**: FSM, behavior trees, utility AI, GOAP, agent behaviors +**When to route**: Need intelligent agent behavior (enemies, NPCs, units) +**Key question**: "Do agents need to make decisions and act autonomously?" + +### 4. traffic-and-pathfinding +**What it teaches**: A*, navmesh, flow fields, traffic simulation, congestion +**When to route**: Need agents to navigate environments or simulate traffic +**Key question**: "Do entities need to find paths or simulate traffic flow?" + +### 5. economic-simulation-patterns +**What it teaches**: Supply/demand, markets, trade networks, price discovery +**When to route**: Need economic systems (trading, markets, resources) +**Key question**: "Does the game involve trade, economy, or resource markets?" + +### 6. ecosystem-simulation +**What it teaches**: Predator-prey dynamics, food chains, population control +**When to route**: Need living ecosystems with wildlife populations +**Key question**: "Do I need animals/plants that breed, eat, and die naturally?" + +### 7. crowd-simulation +**What it teaches**: Boids, formations, social forces, LOD for crowds +**When to route**: Need large groups moving together (crowds, flocks, armies) +**Key question**: "Do I need many entities moving as a coordinated group?" + +### 8. weather-and-time +**What it teaches**: Day/night cycles, weather systems, seasonal effects +**When to route**: Need atmospheric effects or time-based gameplay +**Key question**: "Does the game need time progression or weather?" + +### 9. performance-optimization-for-sims +**What it teaches**: Profiling, spatial partitioning, LOD, time-slicing, caching +**When to route**: Performance problems with existing simulation +**Key question**: "Is my simulation too slow?" + +### 10. debugging-simulation-chaos +**What it teaches**: Systematic debugging, desync detection, determinism, chaos prevention +**When to route**: Simulation behaves incorrectly, chaotically, or unpredictably +**Key question**: "Is my simulation broken, desyncing, or chaotic?" + +--- + +## CORE ROUTING FRAMEWORK + +### The Decision Tree + +Follow this decision tree for ALL simulation tasks: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ STEP 1: ALWAYS START HERE │ +│ ═══════════════════════════════════════════════════════════ │ +│ Route to: simulation-vs-faking │ +│ │ +│ Questions to ask: │ +│ • Do I need to simulate this at all? │ +│ • What level of detail is required? │ +│ • What can I fake or approximate? │ +│ • Where is the player's attention focused? │ +│ │ +│ This prevents the #1 mistake: over-engineering systems │ +│ that could be faked. │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ STEP 2: ROUTE TO SPECIFIC SIMULATION TYPE(S) │ +│ ═══════════════════════════════════════════════════════════ │ +│ Identify which simulation domains apply: │ +│ │ +│ Physics domain → physics-simulation-patterns │ +│ AI domain → ai-and-agent-simulation │ +│ Pathfinding domain → traffic-and-pathfinding │ +│ Economy domain → economic-simulation-patterns │ +│ Ecosystem domain → ecosystem-simulation │ +│ Crowds domain → crowd-simulation │ +│ Atmosphere domain → weather-and-time │ +│ │ +│ Multiple domains? Route to ALL applicable skills. │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ STEP 3: IF PERFORMANCE ISSUES ARISE │ +│ ═══════════════════════════════════════════════════════════ │ +│ Route to: performance-optimization-for-sims │ +│ │ +│ Triggers: │ +│ • Frame rate drops below 60 FPS │ +│ • Profiler shows simulation bottleneck │ +│ • Agent count causes slowdown │ +│ • Simulation gets expensive at scale │ +│ │ +│ WARNING: Don't route here prematurely! │ +│ Premature optimization wastes time. │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ STEP 4: IF BUGS/CHAOS OCCUR │ +│ ═══════════════════════════════════════════════════════════ │ +│ Route to: debugging-simulation-chaos │ +│ │ +│ Triggers: │ +│ • Simulation behaves chaotically/unpredictably │ +│ • Multiplayer desyncs │ +│ • Physics explosions or NaN values │ +│ • Agents stuck or behaving erratically │ +│ • Systems producing nonsensical results │ +│ │ +│ This is a REACTIVE skill - only use when broken. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Routing Principles + +**Principle 1: simulation-vs-faking is ALWAYS step 1** +- Even if you "know" you need simulation, validate this assumption +- Prevents 90% of over-engineering disasters +- Takes 5 minutes, saves hours of wasted work + +**Principle 2: Multiple domains are common** +- Most games need 2-4 simulation types +- Route to ALL applicable skills +- Order matters (see workflow patterns below) + +**Principle 3: Optimization comes AFTER implementation** +- Don't route to performance-optimization-for-sims until you have a working simulation +- Profile first, optimize later +- Premature optimization is the root of all evil + +**Principle 4: Debugging is reactive, not proactive** +- Only route to debugging-simulation-chaos when something is broken +- Don't use it as a preventative measure +- Fix the bug, THEN refactor to prevent recurrence + +--- + +## ROUTING SCENARIOS: 20 Examples + +### Scenario 1: "I want realistic vehicle physics" + +**Analysis**: Needs physics simulation for vehicle dynamics + +**Routing**: +1. simulation-vs-faking → Confirm physics is needed (vs kinematic movement) +2. physics-simulation-patterns → Implement vehicle physics + +**Why**: Vehicles benefit from real physics (suspension, friction, weight transfer). Players notice when physics feels wrong. + +--- + +### Scenario 2: "I need traffic in my city builder" + +**Analysis**: Needs pathfinding and traffic flow for many vehicles + +**Routing**: +1. simulation-vs-faking → Determine level of detail (full per-vehicle sim vs aggregate flow) +2. traffic-and-pathfinding → Implement pathfinding and traffic simulation + +**Why**: City builders need traffic that looks realistic but can scale to thousands of vehicles. Full physics per-vehicle would be overkill. + +--- + +### Scenario 3: "I'm building an RTS game" + +**Analysis**: Multiple simulation domains (AI, pathfinding, possibly physics) + +**Routing**: +1. simulation-vs-faking → Determine what level of detail for each system +2. ai-and-agent-simulation → Unit AI and decision making +3. traffic-and-pathfinding → Unit movement and formation pathfinding +4. (Optional) physics-simulation-patterns → If units have physics-based movement + +**Why**: RTS games need multiple simulation types working together. Order matters: AI decides what to do, pathfinding determines how to get there, physics (if used) handles movement. + +--- + +### Scenario 4: "My ecosystem keeps collapsing" + +**Analysis**: Existing simulation is broken (extinction, runaway growth, chaos) + +**Routing**: +1. debugging-simulation-chaos → Systematic investigation of collapse +2. ecosystem-simulation → Review and fix population dynamics + +**Why**: This is a bug/chaos situation, so debugging comes first. After identifying root cause, use ecosystem skill to fix the math. + +--- + +### Scenario 5: "Frame rate drops with 1000 agents" + +**Analysis**: Performance bottleneck in existing simulation + +**Routing**: +1. performance-optimization-for-sims → Profile and optimize + +**Why**: This is a pure performance problem. No need to revisit design—just optimize what exists. + +--- + +### Scenario 6: "I need realistic NPC daily routines" + +**Analysis**: Agent behavior and time systems + +**Routing**: +1. simulation-vs-faking → Do NPCs need full daily simulation or scheduled events? +2. ai-and-agent-simulation → NPC decision making and behaviors +3. weather-and-time → Day/night cycle for scheduling + +**Why**: Daily routines involve both AI (what NPCs do) and time (when they do it). simulation-vs-faking determines if you simulate every minute or teleport NPCs between scheduled activities. + +--- + +### Scenario 7: "I'm making a survival game with hunting" + +**Analysis**: Multiple domains (ecosystem, AI, physics) + +**Routing**: +1. simulation-vs-faking → Determine simulation detail level +2. ecosystem-simulation → Animal populations and reproduction +3. ai-and-agent-simulation → Animal behaviors (flee, hunt, graze) +4. (Optional) physics-simulation-patterns → If using ragdolls or physics-based hunting + +**Why**: Survival games need functioning ecosystems with believable animal behavior. + +--- + +### Scenario 8: "I need a trading system for my MMO" + +**Analysis**: Economic simulation with many players + +**Routing**: +1. simulation-vs-faking → Determine if you need simulated economy or just UI +2. economic-simulation-patterns → Implement supply/demand and markets + +**Why**: MMO economies are critical gameplay systems. Must decide if NPCs are simulated traders or just price-setting mechanisms. + +--- + +### Scenario 9: "I want flocking birds in the background" + +**Analysis**: Visual effect with crowd behavior + +**Routing**: +1. simulation-vs-faking → Birds are background, so probably fake or very simple +2. crowd-simulation → If simulating, use boids algorithm with heavy LOD + +**Why**: Background birds don't need full simulation. Simple boids with aggressive LOD gives convincing results cheaply. + +--- + +### Scenario 10: "My physics simulation explodes randomly" + +**Analysis**: Physics instability bug + +**Routing**: +1. debugging-simulation-chaos → Identify NaN sources, instability triggers +2. physics-simulation-patterns → Review integration method and constraints + +**Why**: Physics explosions are a specific bug pattern. Debug first to identify the trigger (divide-by-zero, large timesteps, constraint failures). + +--- + +### Scenario 11: "I need weather that affects gameplay" + +**Analysis**: Atmospheric effects with gameplay integration + +**Routing**: +1. simulation-vs-faking → Determine weather complexity (scripted vs simulated) +2. weather-and-time → Implement weather systems and effects + +**Why**: Gameplay-affecting weather needs more than visual effects. Must integrate with movement, visibility, audio, etc. + +--- + +### Scenario 12: "I want a battle royale storm circle" + +**Analysis**: Zone simulation with player effects + +**Routing**: +1. simulation-vs-faking → Storm is gameplay mechanic, not realistic weather +2. (Skip detailed simulation) → Just implement zone shrinking with damage + +**Why**: Battle royale storms are game mechanics disguised as weather. No need for simulation-tactics at all—just implement the zone math directly. + +--- + +### Scenario 13: "My multiplayer game desyncs constantly" + +**Analysis**: Determinism failure causing desyncs + +**Routing**: +1. debugging-simulation-chaos → Identify sources of non-determinism +2. (Revisit implementation skills) → Fix simulation to be deterministic + +**Why**: Desyncs are always determinism bugs. Debug first to find the non-deterministic code (floating point, random, iteration order). + +--- + +### Scenario 14: "I need crowds for a stadium game" + +**Analysis**: Large crowds, mostly visual + +**Routing**: +1. simulation-vs-faking → Crowds are background, so heavy faking likely +2. crowd-simulation → If needed, use heavy LOD (simulate near, animate far) + +**Why**: Stadium crowds are visual atmosphere. Most can be animated sprites. Only simulate visible, close crowds. + +--- + +### Scenario 15: "I'm making a city builder with seasons" + +**Analysis**: Multiple systems (time, economy, possibly ecosystem) + +**Routing**: +1. simulation-vs-faking → Determine simulation vs scripted events +2. weather-and-time → Seasons and time progression +3. economic-simulation-patterns → Seasonal resource production changes +4. (Optional) ecosystem-simulation → If wildlife/farming is simulated + +**Why**: Seasons affect multiple systems. Time system is the core, but economy and ecosystem may need seasonal adjustments. + +--- + +### Scenario 16: "I want realistic wind affecting projectiles" + +**Analysis**: Physics simulation with environmental forces + +**Routing**: +1. simulation-vs-faking → Is wind gameplay-critical or just visual? +2. physics-simulation-patterns → Add wind force to projectile integration + +**Why**: If wind is gameplay-critical (archery, golf), simulate it in physics. If just visual, fake it with particle effects. + +--- + +### Scenario 17: "I need zombie hordes pathfinding to players" + +**Analysis**: Large-scale pathfinding with crowd behavior + +**Routing**: +1. simulation-vs-faking → Determine per-zombie detail level +2. traffic-and-pathfinding → Flow fields or hierarchical pathfinding +3. crowd-simulation → Zombie horde movement and avoidance +4. ai-and-agent-simulation → Individual zombie behaviors (attack, wander) + +**Why**: Zombie hordes need scalable pathfinding (flow fields) and crowd behavior. Individual AI can be simple utility-based decisions. + +--- + +### Scenario 18: "I'm making a fishing game" + +**Analysis**: Ecosystem simulation with simple physics + +**Routing**: +1. simulation-vs-faking → Do fish need full ecosystem or just spawn management? +2. ecosystem-simulation → If full ecosystem, use population dynamics +3. ai-and-agent-simulation → Fish behaviors (schooling, feeding, fleeing) +4. (Optional) physics-simulation-patterns → Rod physics and fish fighting + +**Why**: Fishing games can range from arcade (fake everything) to simulation (full ecosystem). simulation-vs-faking determines the approach. + +--- + +### Scenario 19: "I need a day/night cycle but no weather" + +**Analysis**: Time system only + +**Routing**: +1. simulation-vs-faking → Simple time cycle, no need for complex simulation +2. weather-and-time → Implement day/night cycle (skip weather section) + +**Why**: Day/night cycles are straightforward. Use weather-and-time skill but skip the weather simulation parts. + +--- + +### Scenario 20: "My steering behaviors make agents jitter" + +**Analysis**: Implementation bug in agent movement + +**Routing**: +1. debugging-simulation-chaos → Identify jitter source (probably oscillation) +2. ai-and-agent-simulation → Review steering behavior math and damping + +**Why**: Jittering is a specific bug (agents oscillating around target). Debug to confirm, then fix in AI implementation. + +--- + +## MULTI-SKILL WORKFLOWS: Common Combinations + +### Workflow 1: RTS/Strategy Game +**Goal**: Real-time strategy game with units, economy, and combat + +**Skills needed**: +1. simulation-vs-faking → Define simulation detail level +2. ai-and-agent-simulation → Unit AI (FSM or utility AI) +3. traffic-and-pathfinding → Unit movement and formation pathfinding +4. (Optional) economic-simulation-patterns → Resource gathering and trade +5. (Optional) crowd-simulation → Large army formations +6. performance-optimization-for-sims → Once working, optimize for 1000+ units + +**Order rationale**: +- simulation-vs-faking first (prevents over-engineering) +- AI before pathfinding (decide WHAT to do before HOW to get there) +- Economy can be developed in parallel with AI/pathfinding +- Performance optimization comes last (only optimize what's proven to work) + +**Time estimate**: 2-4 weeks for core systems + +--- + +### Workflow 2: Survival Game +**Goal**: Open-world survival with hunting, ecosystems, and weather + +**Skills needed**: +1. simulation-vs-faking → Define fidelity for each system +2. ecosystem-simulation → Animal populations and food chains +3. ai-and-agent-simulation → Animal behaviors (flee, hunt, graze) +4. weather-and-time → Day/night cycle, seasons, weather effects +5. (Optional) physics-simulation-patterns → Ragdolls and projectile physics +6. performance-optimization-for-sims → Optimize ecosystem and AI + +**Order rationale**: +- Ecosystem provides the living world foundation +- AI makes animals behave believably +- Weather adds atmosphere and gameplay variety +- Physics can be added later if needed + +**Time estimate**: 3-6 weeks for core systems + +--- + +### Workflow 3: City Builder +**Goal**: City simulation with traffic, economy, and population + +**Skills needed**: +1. simulation-vs-faking → Determine citizen simulation detail +2. traffic-and-pathfinding → Vehicle and pedestrian pathfinding +3. economic-simulation-patterns → Resource production and trade +4. (Optional) ai-and-agent-simulation → Individual citizen behaviors +5. (Optional) weather-and-time → Day/night cycle and seasons +6. performance-optimization-for-sims → Optimize for 10,000+ citizens/vehicles + +**Order rationale**: +- Traffic is often the most visible system (do first) +- Economy drives city growth and resource flow +- Individual citizen AI is often faked (use simulation-vs-faking to decide) +- Weather is visual polish (can be added later) + +**Time estimate**: 4-8 weeks for core systems + +--- + +### Workflow 4: MMO with Economy +**Goal**: Multiplayer game with player-driven economy + +**Skills needed**: +1. simulation-vs-faking → Determine if NPCs simulate or just set prices +2. economic-simulation-patterns → Market systems and price discovery +3. (Optional) ai-and-agent-simulation → NPC trader behaviors +4. debugging-simulation-chaos → Ensure determinism for server authority + +**Order rationale**: +- Economy is core gameplay loop +- simulation-vs-faking determines if economy is supply/demand sim or just price database +- Debugging skill ensures economy doesn't desync between clients/server + +**Time estimate**: 2-4 weeks for economy systems + +--- + +### Workflow 5: Battle Royale +**Goal**: 100-player battle royale with shrinking zone + +**Skills needed**: +1. simulation-vs-faking → Determine detail level for distant players +2. (Optional) physics-simulation-patterns → Projectile physics and vehicle physics +3. (Optional) traffic-and-pathfinding → Vehicle pathfinding if vehicles exist +4. debugging-simulation-chaos → Ensure deterministic combat for server authority + +**Order rationale**: +- Battle royale zone doesn't need simulation-tactics (it's just math) +- Most complexity is in netcode and server authority, not simulation +- Use simulation-vs-faking to LOD distant players aggressively + +**Time estimate**: 1-2 weeks (most work is netcode, not simulation) + +--- + +### Workflow 6: Open World with Traffic and Pedestrians +**Goal**: GTA-style open world with vehicles and pedestrians + +**Skills needed**: +1. simulation-vs-faking → Determine simulation radius around player +2. physics-simulation-patterns → Vehicle physics +3. traffic-and-pathfinding → Vehicle and pedestrian pathfinding +4. crowd-simulation → Pedestrian crowds and formations +5. ai-and-agent-simulation → NPC behaviors and reactions +6. performance-optimization-for-sims → LOD systems for distant entities + +**Order rationale**: +- simulation-vs-faking defines the simulation bubble (near=full, far=fake) +- Physics for vehicles player can interact with +- Pathfinding for navigation +- Crowd simulation for believable pedestrian movement +- AI for NPC reactions to player + +**Time estimate**: 6-12 weeks for core systems + +--- + +### Workflow 7: Ecosystem Simulation Game +**Goal**: Nature simulation (Eco, Spore, SimEarth style) + +**Skills needed**: +1. simulation-vs-faking → Determine agent-based vs equation-based balance +2. ecosystem-simulation → Predator-prey dynamics and food chains +3. ai-and-agent-simulation → Animal/plant behaviors +4. weather-and-time → Seasons affecting ecosystem +5. (Optional) economic-simulation-patterns → If resources have market value +6. debugging-simulation-chaos → Prevent extinction cascades and chaos + +**Order rationale**: +- Ecosystem is the core gameplay loop +- AI makes individual organisms believable +- Weather adds environmental pressure +- Debugging prevents catastrophic collapses + +**Time estimate**: 4-8 weeks for core systems + +--- + +### Workflow 8: Physics-Heavy Game (Racing, Destruction) +**Goal**: Game where physics is core gameplay + +**Skills needed**: +1. simulation-vs-faking → Confirm full physics is needed (it usually is) +2. physics-simulation-patterns → Core physics implementation +3. performance-optimization-for-sims → Optimize collision detection and integration +4. debugging-simulation-chaos → Fix physics explosions and instability + +**Order rationale**: +- Physics is the foundation (do first) +- Optimization critical for maintaining 60 FPS with complex physics +- Debugging essential for stability + +**Time estimate**: 3-6 weeks for physics systems + +--- + +## COMMON ROUTING MISTAKES + +### Mistake 1: Skipping simulation-vs-faking +**Symptom**: Over-engineered simulation that could have been faked + +**Example**: +- Building full ecosystem for background birds that are never scrutinized +- Simulating NPC hunger/sleep when player never notices +- Full traffic simulation for distant cars player can't interact with + +**Fix**: ALWAYS route to simulation-vs-faking first. Ask "Will player notice if I fake this?" + +**Cost of mistake**: Weeks of wasted work, ongoing performance burden + +--- + +### Mistake 2: Premature optimization +**Symptom**: Routing to performance-optimization-for-sims before implementation is complete + +**Example**: +- Implementing LOD systems before having working simulation +- Using spatial partitioning before knowing if it's needed +- Caching pathfinding before pathfinding exists + +**Fix**: Profile first, optimize later. Only route to performance-optimization-for-sims when: +- You have working simulation +- You have measured performance problem +- Profiler shows bottleneck + +**Cost of mistake**: Wasted time optimizing code that might change, or optimizing the wrong thing + +--- + +### Mistake 3: Not debugging systematically +**Symptom**: Trying to fix bugs by changing random things, routing to implementation skills instead of debugging-simulation-chaos + +**Example**: +- "Physics explodes, let me try different integration method" (should debug first) +- "Ecosystem collapses, let me add more food" (should debug why it collapses) +- "Pathfinding breaks, let me rewrite the algorithm" (should debug the existing code) + +**Fix**: When simulation is broken, ALWAYS route to debugging-simulation-chaos first. Identify root cause before attempting fixes. + +**Cost of mistake**: Bug persists, or you "fix" symptom without addressing cause + +--- + +### Mistake 4: Wrong skill for the domain +**Symptom**: Using ai-and-agent-simulation when you need traffic-and-pathfinding, etc. + +**Example**: +- Using ai-and-agent-simulation for pathfinding (use traffic-and-pathfinding instead) +- Using physics-simulation-patterns for kinematic movement (use ai-and-agent-simulation) +- Using crowd-simulation for trading (use economic-simulation-patterns) + +**Fix**: Understand what each skill covers. Pathfinding is NOT AI. Physics is NOT movement. Crowds are NOT flocking AI. + +**Cost of mistake**: Learning wrong techniques for your problem + +--- + +### Mistake 5: Implementing in wrong order +**Symptom**: Building dependent system before foundation + +**Example**: +- Implementing AI behaviors before pathfinding exists (AI can't move) +- Building economy before resource sources exist (nothing to trade) +- Adding weather effects before day/night cycle (no time progression) + +**Fix**: Follow the dependency order in multi-skill workflows. Foundation first, then dependent systems. + +**Cost of mistake**: Rework when foundation changes breaks dependent systems + +--- + +### Mistake 6: Ignoring multiplayer determinism +**Symptom**: Building single-player simulation without considering multiplayer needs + +**Example**: +- Using floating-point physics for multiplayer game (desyncs) +- Random number generation without shared seed (desyncs) +- Iterating unordered collections (desyncs) + +**Fix**: If multiplayer is planned, route to debugging-simulation-chaos early to learn determinism requirements. + +**Cost of mistake**: Complete rewrite to fix desyncs + +--- + +### Mistake 7: Over-combining skills +**Symptom**: Trying to use every skill when only 1-2 are needed + +**Example**: +- Simple puzzle game doesn't need ecosystem-simulation +- Turn-based game doesn't need performance-optimization-for-sims +- Static world doesn't need weather-and-time + +**Fix**: Route to ONLY the skills you actually need. More skills = more complexity. + +**Cost of mistake**: Wasted time learning and implementing unnecessary systems + +--- + +## QUICK REFERENCE TABLE + +| User Need | Primary Skill | Secondary Skills | Also Consider | +|-----------|---------------|------------------|---------------| +| **Vehicle physics** | physics-simulation-patterns | - | performance-optimization (if many vehicles) | +| **City traffic** | traffic-and-pathfinding | simulation-vs-faking | performance-optimization (scale to 10k) | +| **NPC AI** | ai-and-agent-simulation | simulation-vs-faking | traffic-and-pathfinding (if NPCs move) | +| **RTS units** | ai-and-agent-simulation, traffic-and-pathfinding | crowd-simulation (formations) | performance-optimization (1000+ units) | +| **Trading system** | economic-simulation-patterns | simulation-vs-faking | ai-and-agent-simulation (NPC traders) | +| **Wildlife/hunting** | ecosystem-simulation | ai-and-agent-simulation | simulation-vs-faking (detail level) | +| **Crowds** | crowd-simulation | simulation-vs-faking | performance-optimization (scale) | +| **Day/night** | weather-and-time | simulation-vs-faking | - | +| **Weather effects** | weather-and-time | physics-simulation-patterns (wind) | - | +| **Seasons** | weather-and-time | economic-simulation (seasonal changes) | ecosystem-simulation (if wildlife) | +| **Pathfinding** | traffic-and-pathfinding | simulation-vs-faking | performance-optimization (many agents) | +| **Flocking birds** | crowd-simulation | simulation-vs-faking | performance-optimization (LOD) | +| **Ragdolls** | physics-simulation-patterns | - | debugging-simulation-chaos (stability) | +| **Destructibles** | physics-simulation-patterns | simulation-vs-faking (detail) | performance-optimization (debris) | +| **Performance issue** | performance-optimization-for-sims | (original implementation skill) | debugging-simulation-chaos (if bug) | +| **Physics explodes** | debugging-simulation-chaos | physics-simulation-patterns | - | +| **Ecosystem collapse** | debugging-simulation-chaos | ecosystem-simulation | - | +| **Multiplayer desync** | debugging-simulation-chaos | (any affected skills) | - | +| **Zombie hordes** | traffic-and-pathfinding, crowd-simulation | ai-and-agent-simulation | performance-optimization (scale) | +| **Fishing game** | ecosystem-simulation | ai-and-agent-simulation, physics-simulation-patterns | simulation-vs-faking (realism level) | + +--- + +## DECISION FLOWCHART + +Use this flowchart for quick routing decisions: + +``` +START: User describes simulation need + ↓ +[Is this DEFINITELY about simulation?] + ├─ No → Don't use simulation-tactics at all + └─ Yes → Continue + ↓ +[Route to: simulation-vs-faking] + "Do I simulate, fake, or hybrid?" + ↓ +[Identify domain(s)] + ├─ Physics? → physics-simulation-patterns + ├─ AI/Agents? → ai-and-agent-simulation + ├─ Pathfinding? → traffic-and-pathfinding + ├─ Economy? → economic-simulation-patterns + ├─ Ecosystem? → ecosystem-simulation + ├─ Crowds? → crowd-simulation + └─ Weather/Time? → weather-and-time + ↓ +[Is simulation ALREADY implemented?] + ├─ No → Use identified skill(s) to implement + └─ Yes → Continue + ↓ +[Is there a PERFORMANCE problem?] + ├─ Yes → performance-optimization-for-sims + └─ No → Continue + ↓ +[Is there a BUG/CHAOS problem?] + ├─ Yes → debugging-simulation-chaos + └─ No → Implementation complete! +``` + +--- + +## EXPERT ROUTING TIPS + +### Tip 1: Listen for hidden requirements +Users often describe WHAT they want without understanding WHICH simulation type they need. + +**Examples**: +- "I want intelligent enemies" → Could be ai-and-agent-simulation OR traffic-and-pathfinding OR both +- "I need realistic physics" → Could be physics-simulation-patterns OR just kinematic movement +- "I want a living world" → Could be ecosystem-simulation OR ai-and-agent-simulation OR weather-and-time + +**Fix**: Ask clarifying questions: +- "Do enemies need to navigate complex terrain?" (pathfinding) +- "Do enemies need to make tactical decisions?" (AI) +- "Does 'living world' mean wildlife, weather, or both?" (ecosystem vs weather) + +### Tip 2: Recognize anti-patterns +Some phrases indicate the user is heading toward common mistakes: + +**Red flags**: +- "I want to simulate EVERYTHING" → Over-engineering, route to simulation-vs-faking +- "It needs to be perfectly realistic" → Perfectionism trap, route to simulation-vs-faking +- "I'll optimize later" → True, but ensure they know when "later" is (after profiling) +- "I changed one parameter and it exploded" → Chaos, route to debugging-simulation-chaos +- "It works on my machine but desyncs in multiplayer" → Determinism bug, route to debugging-simulation-chaos + +### Tip 3: Recognize interdependencies +Some skill combinations have ordering requirements: + +**Dependencies**: +- ai-and-agent-simulation depends on traffic-and-pathfinding (if agents need to navigate) +- crowd-simulation depends on traffic-and-pathfinding (for underlying navigation) +- ecosystem-simulation depends on ai-and-agent-simulation (for animal behaviors) +- performance-optimization-for-sims depends on having working simulation first + +**Rule**: Foundation skills (simulation-vs-faking, core implementations) before dependent skills (optimization, debugging) + +### Tip 4: Scale determines routing +The number of entities changes which skills are needed: + +**Scale breakpoints**: +- **< 10 entities**: Basic implementation, no special optimization +- **10-100 entities**: May need performance-optimization-for-sims +- **100-1000 entities**: Definitely need performance-optimization-for-sims, spatial partitioning, LOD +- **1000+ entities**: Need aggressive optimization, time-slicing, hybrid LOD + +**Example**: "I need 10 NPCs" vs "I need 10,000 NPCs" route to same implementation skill, but latter ALSO routes to performance-optimization-for-sims. + +### Tip 5: Genre provides context +Game genre suggests which skills are commonly needed: + +**Genre routing patterns**: +- **RTS/Strategy**: ai-and-agent-simulation + traffic-and-pathfinding + performance-optimization +- **Survival**: ecosystem-simulation + ai-and-agent-simulation + weather-and-time +- **City Builder**: traffic-and-pathfinding + economic-simulation + simulation-vs-faking +- **Racing**: physics-simulation-patterns + performance-optimization +- **MMO**: economic-simulation + debugging-simulation-chaos (determinism) +- **Open World**: traffic-and-pathfinding + crowd-simulation + weather-and-time +- **Battle Royale**: simulation-vs-faking (aggressive LOD) + debugging-simulation-chaos (determinism) + +Don't over-assume based on genre, but use it as a starting hypothesis. + +--- + +## IMPLEMENTATION CHECKLIST + +When routing to multiple skills, use this checklist to ensure proper workflow: + +### Phase 1: Planning (Always First) +- [ ] Route to simulation-vs-faking +- [ ] Identify all applicable simulation domains +- [ ] Determine implementation order based on dependencies +- [ ] Validate that simulation is actually needed + +### Phase 2: Implementation (Core Systems) +- [ ] Implement foundation skills first (pathfinding before AI, etc.) +- [ ] Test each system independently before integration +- [ ] Ensure determinism if multiplayer is planned +- [ ] Validate against "good enough" threshold from simulation-vs-faking + +### Phase 3: Integration (Combining Systems) +- [ ] Integrate systems in dependency order +- [ ] Test combined systems at target scale +- [ ] Profile to identify bottlenecks (if any) + +### Phase 4: Optimization (Only If Needed) +- [ ] Profile to measure performance +- [ ] Route to performance-optimization-for-sims only if bottleneck exists +- [ ] Re-test after optimization +- [ ] Validate gameplay still feels correct + +### Phase 5: Debugging (Only If Broken) +- [ ] Route to debugging-simulation-chaos if bugs occur +- [ ] Use systematic debugging process +- [ ] Fix root cause, not symptoms +- [ ] Add prevention measures + +--- + +## META-SKILL SELF-CHECK + +After using this meta-skill, verify your routing with these questions: + +**Routing accuracy**: +- [ ] Did I start with simulation-vs-faking? +- [ ] Did I identify ALL applicable simulation domains? +- [ ] Did I avoid routing to performance-optimization-for-sims prematurely? +- [ ] Did I only route to debugging-simulation-chaos for actual bugs? + +**Workflow correctness**: +- [ ] Am I implementing foundation skills before dependent skills? +- [ ] Have I considered interdependencies between skills? +- [ ] Is the implementation order logical? + +**Efficiency**: +- [ ] Am I using the minimum skills needed? +- [ ] Have I avoided over-engineering? +- [ ] Am I respecting the "good enough" threshold? + +**Completeness**: +- [ ] Have I considered multiplayer determinism (if applicable)? +- [ ] Have I planned for scale (if thousands of entities)? +- [ ] Have I validated gameplay implications? + +--- + +## ADVANCED ROUTING: Edge Cases + +### Edge Case 1: "I don't know what kind of simulation I need" +**Symptom**: User describes game but unclear which simulation domains apply + +**Process**: +1. Route to simulation-vs-faking anyway (helps clarify requirements) +2. Ask probing questions about specific systems: + - "Do you have moving agents?" (pathfinding/AI) + - "Is there combat?" (physics/AI) + - "Is there economy/trading?" (economic) + - "Is there wildlife?" (ecosystem) +3. Route to identified domains + +**Example**: "I'm making a survival game" → Ask about hunting (ecosystem), crafting (economy), weather (weather-and-time), etc. + +### Edge Case 2: "My simulation needs to be deterministic" +**Symptom**: Multiplayer, replay system, or deterministic requirement + +**Process**: +1. Route to debugging-simulation-chaos EARLY (learn determinism requirements) +2. Then route to implementation skill(s) +3. Implement with determinism constraints from start (cheaper than refactoring) + +**Why**: Determinism requirements affect implementation decisions. Better to know early. + +### Edge Case 3: "I need simulation but performance is already a concern" +**Symptom**: Performance budget known to be tight from start + +**Process**: +1. Route to simulation-vs-faking (aggressive use of faking/LOD) +2. Route to implementation skill(s) +3. Route to performance-optimization-for-sims for architectural guidance +4. Implement with performance in mind from start + +**Why**: If performance is constrained, design for performance from the beginning. Don't implement naive version first. + +### Edge Case 4: "I'm refactoring existing simulation" +**Symptom**: Working simulation exists but needs improvement + +**Process**: +1. If broken: debugging-simulation-chaos first +2. If slow: performance-optimization-for-sims +3. If wrong architecture: simulation-vs-faking to reconsider design, then relevant implementation skill + +**Why**: Refactoring is different from greenfield. Identify the problem (bug, performance, design) before routing. + +### Edge Case 5: "I need simulation for tool/editor, not game" +**Symptom**: Simulation is for preview/visualization, not runtime gameplay + +**Process**: +1. Route to simulation-vs-faking (tools have different constraints than games) +2. Route to implementation skill(s) +3. Optimize for accuracy over performance (tools can be slower) + +**Why**: Tool simulations prioritize accuracy and debuggability over frame rate. + +--- + +## CONCLUSION: The Art of Routing + +Effective routing requires understanding: +1. **What each skill provides** (domain coverage) +2. **When to use each skill** (triggers and context) +3. **How skills combine** (workflows and dependencies) +4. **What mistakes to avoid** (anti-patterns) + +Master this meta-skill to navigate the simulation-tactics skillpack efficiently. The right routing decision saves hours of wasted work. + +**Remember the golden rule**: ALWAYS start with simulation-vs-faking, even when you "know" you need simulation. The 5 minutes spent validating your assumptions prevents the hours spent over-engineering systems that could have been faked. + +--- + +## Simulation Tactics Specialist Skills Catalog + +After routing, load the appropriate specialist skill for detailed guidance: + +1. [simulation-vs-faking.md](simulation-vs-faking.md) - FOUNDATIONAL: Trade-off between full simulation and approximation/faking, when to simulate vs fake, hybrid approaches +2. [physics-simulation-patterns.md](physics-simulation-patterns.md) - Rigid bodies, vehicles, cloth, fluids, integration methods, real-time physics simulation +3. [ai-and-agent-simulation.md](ai-and-agent-simulation.md) - FSM, behavior trees, utility AI, GOAP, agent behaviors, autonomous decision-making +4. [traffic-and-pathfinding.md](traffic-and-pathfinding.md) - A*, navmesh, flow fields, traffic simulation, congestion, navigation +5. [economic-simulation-patterns.md](economic-simulation-patterns.md) - Supply/demand, markets, trade networks, price discovery, resource economies +6. [ecosystem-simulation.md](ecosystem-simulation.md) - Predator-prey dynamics, food chains, population control, wildlife ecosystems +7. [crowd-simulation.md](crowd-simulation.md) - Boids, formations, social forces, LOD for crowds, coordinated group movement +8. [weather-and-time.md](weather-and-time.md) - Day/night cycles, weather systems, seasonal effects, atmospheric simulation +9. [performance-optimization-for-sims.md](performance-optimization-for-sims.md) - Profiling, spatial partitioning, LOD, time-slicing, caching, performance tuning +10. [debugging-simulation-chaos.md](debugging-simulation-chaos.md) - Systematic debugging, desync detection, determinism, chaos prevention + +Now route confidently to the specific skills you need! diff --git a/skills/using-simulation-tactics/ai-and-agent-simulation.md b/skills/using-simulation-tactics/ai-and-agent-simulation.md new file mode 100644 index 0000000..c3a6f3e --- /dev/null +++ b/skills/using-simulation-tactics/ai-and-agent-simulation.md @@ -0,0 +1,2847 @@ + +# AI and Agent Simulation + +## Description +Master finite state machines, behavior trees, utility AI, GOAP, steering behaviors, and pathfinding for game agents. Apply time-slicing, LOD systems, and debug visualization to build performant, believable AI that scales from 10 to 1000+ agents while remaining debuggable and maintainable. + +## When to Use This Skill +Use this skill when implementing or debugging: +- Enemy AI (guards, soldiers, creatures) +- NPC behavior (civilians, merchants, quest givers) +- Squad tactics and group coordination +- Stealth game detection systems +- RTS unit AI and formations +- Any autonomous agent requiring decisions and actions + +Do NOT use this skill for: +- Simple scripted sequences (use timeline/cutscene tools) +- UI automation (use state machines without spatial reasoning) +- Network players (human-controlled) +- Physics-only simulations (see physics-simulation-patterns) + + +## Quick Start (Time-Constrained Implementation) + +If you need working AI quickly (< 4 hours), follow this priority order: + +**CRITICAL (Never Skip)**: +1. **Choose right architecture**: FSM for ≤3 states, Behavior Tree for 4+ states +2. **Use engine pathfinding**: Unity NavMeshAgent, Unreal NavigationSystem +3. **Time-slice updates**: Spread AI across frames (10-20 agents per frame) +4. **Add debug visualization**: State labels, FOV cones, paths (OnDrawGizmos) + +**IMPORTANT (Strongly Recommended)**: +5. Handle pathfinding failures (timeout after 5 seconds of no progress) +6. Add reaction delays (0.2-0.5s) for believability +7. Separate sensing from decision-making (cache sensor data) +8. Test with max agent count (ensure 60 FPS with 100+ agents) + +**CAN DEFER** (Optimize Later): +- Advanced steering behaviors (start with NavMesh avoidance) +- Utility AI or GOAP (start with BT/FSM) +- Squad coordination (get individual AI working first) +- Animation integration (use placeholder states) + +**Example - Unity Guard AI in 30 Minutes**: +```csharp +using UnityEngine; +using UnityEngine.AI; + +public class GuardAI : MonoBehaviour +{ + public Transform[] waypoints; + private NavMeshAgent agent; + private int currentWaypoint = 0; + private Transform player; + + void Start() + { + agent = GetComponent(); + player = GameObject.FindGameObjectWithTag("Player").transform; + } + + void Update() + { + // 1. Patrol (NavMesh handles pathfinding) + if (!agent.hasPath || agent.remainingDistance < 0.5f) + { + agent.SetDestination(waypoints[currentWaypoint].position); + currentWaypoint = (currentWaypoint + 1) % waypoints.Length; + } + + // 2. Simple player detection + float distToPlayer = Vector3.Distance(transform.position, player.position); + if (distToPlayer < 15f) + { + agent.SetDestination(player.position); + } + } + + // 3. Debug visualization + void OnDrawGizmos() + { + Gizmos.color = Color.yellow; + Gizmos.DrawWireSphere(transform.position, 15f); + } +} +``` + +This gives you functional AI. Refine with behavior trees, time-slicing, and steering as needed. + + +## Core Concepts + +### 1. AI Architecture Types + +Different AI architectures suit different complexity levels. Choose based on number of states/behaviors and need for hierarchy. + +#### Finite State Machines (FSM) + +**What**: States with explicit transitions. Each frame, AI is in exactly one state. + +```csharp +// Simple FSM - Good for ≤3 states +public enum State { Patrol, Chase, Attack } + +public class SimpleAI : MonoBehaviour +{ + private State currentState = State.Patrol; + + void Update() + { + switch (currentState) + { + case State.Patrol: + Patrol(); + if (PlayerInSight()) currentState = State.Chase; + break; + + case State.Chase: + Chase(); + if (InAttackRange()) currentState = State.Attack; + if (PlayerLost()) currentState = State.Patrol; + break; + + case State.Attack: + Attack(); + if (!InAttackRange()) currentState = State.Chase; + break; + } + } +} +``` + +**When to Use FSM**: +- ≤3-4 distinct states +- Simple, linear state transitions +- No hierarchical behaviors needed +- Quick prototyping + +**When NOT to Use FSM**: +- 5+ states (transition explosion: 20+ edges) +- Concurrent behaviors (can't patrol AND listen at same time) +- Hierarchical decisions (investigate → move → look around) +- Complex conditionals (too many if-checks in transitions) + +**Real-World Example**: Pac-Man ghosts (4 states: Chase, Scatter, Frightened, Eaten) + +#### Behavior Trees (BT) + +**What**: Hierarchical tree of nodes (selectors, sequences, actions). Evaluated top-to-bottom each tick. + +```csharp +// Behavior Tree - Good for 4+ states with hierarchy +public class BehaviorTreeAI : MonoBehaviour +{ + private BehaviorTree tree; + + void Start() + { + tree = new BehaviorTree( + new Selector( + // Highest priority: Combat + new Sequence( + new Condition(() => PlayerInSight()), + new Selector( + new Sequence( + new Condition(() => InAttackRange()), + new Action(() => Attack()) + ), + new Action(() => Chase()) + ) + ), + // Medium priority: Investigate sounds + new Sequence( + new Condition(() => HasHeardSound()), + new Action(() => Investigate()) + ), + // Low priority: Patrol + new Action(() => Patrol()) + ) + ); + } + + void Update() + { + tree.Tick(); + } +} +``` + +**When to Use BT**: +- 4+ behaviors with priorities +- Hierarchical decisions (combat → melee vs ranged) +- Need composability (reuse investigation subtree) +- Iterating on behavior frequently + +**When NOT to Use BT**: +- Very simple AI (FSM is simpler) +- Need strict state guarantees (BT re-evaluates tree each tick) +- Performance critical (FSM is faster) + +**Real-World Example**: Halo (combat behaviors), Unreal Engine AI (BTNodes) + +#### Utility AI + +**What**: Score all possible actions, pick highest. Each action has utility function (0-1 score). + +```csharp +// Utility AI - Good for context-dependent decisions +public class UtilityAI : MonoBehaviour +{ + void Update() + { + float patrolUtility = CalculatePatrolUtility(); + float combatUtility = CalculateCombatUtility(); + float healUtility = CalculateHealUtility(); + float fleeUtility = CalculateFleeUtility(); + + float maxUtility = Mathf.Max(patrolUtility, combatUtility, healUtility, fleeUtility); + + if (maxUtility == combatUtility) Combat(); + else if (maxUtility == healUtility) Heal(); + else if (maxUtility == fleeUtility) Flee(); + else Patrol(); + } + + float CalculateCombatUtility() + { + float health = GetHealth() / 100f; // 0-1 + float distance = 1f - (DistanceToPlayer() / 50f); // 0-1 (closer = higher) + float ammo = GetAmmo() / 30f; // 0-1 + + // Curve: Only fight if healthy, close, and have ammo + return health * 0.5f + distance * 0.3f + ammo * 0.2f; + } + + float CalculateHealUtility() + { + float health = GetHealth() / 100f; + return (1f - health) * (1f - health); // Quadratic: Low health = urgent + } +} +``` + +**When to Use Utility AI**: +- Context-dependent decisions (low health → heal more important) +- Smooth priority blending (not binary state switches) +- Large action space (10+ possible actions) +- Emergent behavior from scoring + +**When NOT to Use Utility AI**: +- Simple priority lists (BT selector is clearer) +- Need deterministic behavior (scoring can vary) +- Performance critical (scoring all actions is expensive) + +**Real-World Example**: The Sims (needs prioritization), RimWorld (colonist tasks) + +#### Goal-Oriented Action Planning (GOAP) + +**What**: Define goal and available actions. Planner finds action sequence to reach goal. + +```csharp +// GOAP - Good for dynamic problem-solving +public class GOAP : MonoBehaviour +{ + // World state + Dictionary worldState = new Dictionary + { + { "hasWeapon", false }, + { "playerAlive", true }, + { "atCoverPosition", false } + }; + + // Goal + Dictionary goal = new Dictionary + { + { "playerAlive", false } + }; + + // Actions with preconditions and effects + List actions = new List + { + new Action("GetWeapon", + preconditions: new Dictionary { }, + effects: new Dictionary { { "hasWeapon", true } } + ), + new Action("TakeCover", + preconditions: new Dictionary { }, + effects: new Dictionary { { "atCoverPosition", true } } + ), + new Action("KillPlayer", + preconditions: new Dictionary { { "hasWeapon", true }, { "atCoverPosition", true } }, + effects: new Dictionary { { "playerAlive", false } } + ) + }; + + void Start() + { + // Planner creates: GetWeapon → TakeCover → KillPlayer + List plan = GOAPPlanner.Plan(worldState, goal, actions); + ExecutePlan(plan); + } +} +``` + +**When to Use GOAP**: +- Complex problem-solving (dynamic action sequences) +- Unpredictable world states (actions depend on environment) +- Agent autonomy (AI figures out how to achieve goals) +- Long-term planning (3+ step sequences) + +**When NOT to Use GOAP**: +- Simple reactive behavior (patrol, chase) +- Performance critical (planning is expensive) +- Deterministic behavior needed (plans can vary) +- Quick prototyping (complex to implement) + +**Real-World Example**: F.E.A.R. (soldiers dynamically plan tactics), Shadow of Mordor + + +### 2. Pathfinding and Navigation + +AI needs to navigate complex 3D environments. Use engine-provided solutions first. + +#### Unity NavMesh + +**Core Pattern**: +```csharp +using UnityEngine.AI; + +public class NavMeshExample : MonoBehaviour +{ + private NavMeshAgent agent; + + void Start() + { + agent = GetComponent(); + + // Configure agent + agent.speed = 3.5f; + agent.acceleration = 8f; + agent.angularSpeed = 120f; + agent.stoppingDistance = 0.5f; + agent.autoBraking = true; + agent.obstacleAvoidance Quality = ObstacleAvoidanceType.HighQualityObstacleAvoidance; + } + + void Update() + { + // Set destination + if (Input.GetMouseButtonDown(0)) + { + Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); + RaycastHit hit; + if (Physics.Raycast(ray, out hit)) + { + agent.SetDestination(hit.point); + } + } + + // Check if path is valid + if (agent.pathPending) return; + + if (!agent.hasPath || agent.remainingDistance < 0.5f) + { + OnReachedDestination(); + } + } + + void OnReachedDestination() + { + Debug.Log("Arrived!"); + } +} +``` + +**Handling Pathfinding Failures**: +```csharp +void SetDestinationSafe(Vector3 target) +{ + NavMeshPath path = new NavMeshPath(); + bool pathExists = NavMesh.CalculatePath( + transform.position, + target, + NavMesh.AllAreas, + path + ); + + if (pathExists && path.status == NavMeshPathStatus.PathComplete) + { + agent.SetPath(path); + } + else + { + Debug.LogWarning($"No path to {target}"); + OnPathfindingFailed(); + } +} + +void OnPathfindingFailed() +{ + // Fallback behavior + // Option 1: Return to patrol + // Option 2: Find nearest reachable position + // Option 3: Alert player (for debugging) +} +``` + +**Dynamic Obstacles**: +```csharp +// Add NavMeshObstacle to moving objects +NavMeshObstacle obstacle = GetComponent(); +obstacle.carving = true; +obstacle.carveOnlyStationary = false; + +// NavMeshAgent will avoid it automatically +``` + +**Off-Mesh Links** (for jumps, ladders): +```csharp +// 1. Place Off-Mesh Link component in scene (connect two points) +// 2. Agent traverses it automatically, or handle manually: + +void Update() +{ + if (agent.isOnOffMeshLink) + { + StartCoroutine(HandleOffMeshLink()); + } +} + +IEnumerator HandleOffMeshLink() +{ + OffMeshLinkData data = agent.currentOffMeshLinkData; + + // Play jump animation + PlayAnimation("Jump"); + + // Move to end point + float duration = 0.5f; + float elapsed = 0f; + Vector3 startPos = transform.position; + Vector3 endPos = data.endPos; + + while (elapsed < duration) + { + transform.position = Vector3.Lerp(startPos, endPos, elapsed / duration); + elapsed += Time.deltaTime; + yield return null; + } + + agent.CompleteOffMeshLink(); +} +``` + +#### A* Pathfinding (Manual Implementation) + +**When to use**: Custom grids, 2D games, or when NavMesh doesn't fit. + +```csharp +// Simplified A* (complete implementation would be larger) +public class AStarPathfinding +{ + public List FindPath(Vector2Int start, Vector2Int goal, bool[,] walkable) + { + PriorityQueue openSet = new PriorityQueue(); + HashSet closedSet = new HashSet(); + Dictionary allNodes = new Dictionary(); + + Node startNode = new Node(start, 0, Heuristic(start, goal), null); + openSet.Enqueue(startNode, startNode.F); + allNodes[start] = startNode; + + while (openSet.Count > 0) + { + Node current = openSet.Dequeue(); + + if (current.Position == goal) + { + return ReconstructPath(current); + } + + closedSet.Add(current.Position); + + foreach (Vector2Int neighbor in GetNeighbors(current.Position)) + { + if (!walkable[neighbor.x, neighbor.y] || closedSet.Contains(neighbor)) + continue; + + float newG = current.G + 1; // Assuming uniform cost + + if (!allNodes.ContainsKey(neighbor) || newG < allNodes[neighbor].G) + { + Node neighborNode = new Node(neighbor, newG, Heuristic(neighbor, goal), current); + allNodes[neighbor] = neighborNode; + openSet.Enqueue(neighborNode, neighborNode.F); + } + } + } + + return null; // No path found + } + + float Heuristic(Vector2Int a, Vector2Int b) + { + return Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y); // Manhattan distance + } + + List ReconstructPath(Node node) + { + List path = new List(); + while (node != null) + { + path.Add(node.Position); + node = node.Parent; + } + path.Reverse(); + return path; + } +} + +class Node +{ + public Vector2Int Position; + public float G; // Cost from start + public float H; // Heuristic to goal + public float F => G + H; + public Node Parent; + + public Node(Vector2Int pos, float g, float h, Node parent) + { + Position = pos; + G = g; + H = h; + Parent = parent; + } +} +``` + + +### 3. Steering Behaviors + +**What**: Low-level movement behaviors for smooth, natural-looking motion. + +#### Basic Steering - Seek and Flee + +```csharp +public class SteeringBehaviors : MonoBehaviour +{ + public float maxSpeed = 5f; + public float maxForce = 10f; + + private Vector3 velocity = Vector3.zero; + + // Seek: Move toward target + Vector3 Seek(Vector3 targetPosition) + { + Vector3 desired = (targetPosition - transform.position).normalized * maxSpeed; + Vector3 steer = desired - velocity; + return Vector3.ClampMagnitude(steer, maxForce); + } + + // Flee: Move away from target + Vector3 Flee(Vector3 targetPosition) + { + Vector3 desired = (transform.position - targetPosition).normalized * maxSpeed; + Vector3 steer = desired - velocity; + return Vector3.ClampMagnitude(steer, maxForce); + } + + void Update() + { + Vector3 steering = Seek(targetPosition); + + velocity += steering * Time.deltaTime; + velocity = Vector3.ClampMagnitude(velocity, maxSpeed); + + transform.position += velocity * Time.deltaTime; + } +} +``` + +#### Flocking - Separation, Alignment, Cohesion + +```csharp +public class Flocking : MonoBehaviour +{ + public float separationRadius = 2f; + public float alignmentRadius = 5f; + public float cohesionRadius = 5f; + + public float separationWeight = 1.5f; + public float alignmentWeight = 1.0f; + public float cohesionWeight = 1.0f; + + private List neighbors; + + Vector3 Separation() + { + Vector3 steer = Vector3.zero; + int count = 0; + + foreach (var other in neighbors) + { + float dist = Vector3.Distance(transform.position, other.transform.position); + if (dist > 0 && dist < separationRadius) + { + Vector3 diff = transform.position - other.transform.position; + diff = diff.normalized / dist; // Weight by distance + steer += diff; + count++; + } + } + + if (count > 0) + { + steer /= count; + steer = steer.normalized * maxSpeed; + steer -= velocity; + steer = Vector3.ClampMagnitude(steer, maxForce); + } + + return steer; + } + + Vector3 Alignment() + { + Vector3 sum = Vector3.zero; + int count = 0; + + foreach (var other in neighbors) + { + float dist = Vector3.Distance(transform.position, other.transform.position); + if (dist > 0 && dist < alignmentRadius) + { + sum += other.velocity; + count++; + } + } + + if (count > 0) + { + sum /= count; + sum = sum.normalized * maxSpeed; + Vector3 steer = sum - velocity; + return Vector3.ClampMagnitude(steer, maxForce); + } + + return Vector3.zero; + } + + Vector3 Cohesion() + { + Vector3 sum = Vector3.zero; + int count = 0; + + foreach (var other in neighbors) + { + float dist = Vector3.Distance(transform.position, other.transform.position); + if (dist > 0 && dist < cohesionRadius) + { + sum += other.transform.position; + count++; + } + } + + if (count > 0) + { + sum /= count; + return Seek(sum); + } + + return Vector3.zero; + } + + void Update() + { + // Update neighbors (use spatial partitioning in production) + neighbors = FindObjectsOfType().Where(f => f != this).ToList(); + + // Calculate steering forces + Vector3 separation = Separation() * separationWeight; + Vector3 alignment = Alignment() * alignmentWeight; + Vector3 cohesion = Cohesion() * cohesionWeight; + + // Combine + Vector3 acceleration = separation + alignment + cohesion; + velocity += acceleration * Time.deltaTime; + velocity = Vector3.ClampMagnitude(velocity, maxSpeed); + + transform.position += velocity * Time.deltaTime; + } +} +``` + +**When to Use Steering Behaviors**: +- Flocking (birds, fish, crowds) +- Smooth pursuit and evasion +- Obstacle avoidance (in addition to pathfinding) +- Natural-looking movement +- Formations (units staying together) + +**When NOT to Use**: +- Simple point-to-point movement (use NavMesh) +- Grid-based movement (use A*) +- Physics-based movement (use Rigidbody forces) + + +## Decision Frameworks + +### Framework 1: Choosing AI Architecture + +**Decision Tree**: + +``` +Q: How many distinct behaviors/states? +├─ 1-3 states +│ └─ Use FSM (simple, fast, clear) +│ +├─ 4-8 states with hierarchy +│ └─ Use Behavior Tree (composable, maintainable) +│ +├─ 8+ states with context-dependent priorities +│ └─ Use Utility AI (emergent, nuanced) +│ +└─ Dynamic action sequences to achieve goals + └─ Use GOAP (autonomous, adaptive) +``` + +**Complexity Thresholds**: + +| Architecture | Sweet Spot | Max Before Refactor | Example | +|--------------|------------|---------------------|---------| +| FSM | 1-3 states | 5 states | Pac-Man ghost, simple patrol | +| Behavior Tree | 4-10 behaviors | 20 nodes (too deep) | Halo marine, stealth guard | +| Utility AI | 5-15 actions | 30+ actions (slow) | The Sims need system | +| GOAP | 5-20 actions | 50+ actions (planning slow) | F.E.A.R. soldier tactics | + +**Red Flags for FSM**: +- Drawing state diagram results in 20+ transition arrows +- Adding new state requires editing 5+ other states +- States have sub-states (use BT hierarchy instead) +- Same transition logic duplicated across states + +**Example Decision**: +- Patrol guard (3 states: Patrol, Investigate, Chase) → **FSM** +- Stealth guard (8 behaviors: Patrol, Hear, Investigate, Alert, Search, Chase, Attack, CallBackup) → **Behavior Tree** +- RTS villager (12 tasks: Gather wood, mine, build, repair, fight, flee, eat, sleep) → **Utility AI** +- Tactical shooter AI (dynamic cover, flanking, suppression, grenade use) → **GOAP** + + +### Framework 2: Simple AI vs Convincing AI (Good Enough Threshold) + +**Question**: How much AI polish is needed? + +**Factors**: +1. **Player engagement time**: 5-second encounter vs 30-minute boss fight +2. **Player scrutiny**: Background NPC vs primary antagonist +3. **Genre expectations**: Puzzle game vs tactical shooter +4. **Budget/timeline**: Prototype vs AAA release + +**Good Enough Checklist**: + +| Requirement | Quick (< 4 hrs) | Polished (< 40 hrs) | AAA (< 400 hrs) | +|-------------|-----------------|---------------------|-----------------| +| Basic behavior | FSM or BT | BT with 8+ nodes | BT + Utility or GOAP | +| Pathfinding | NavMesh basic | NavMesh + cover system | Custom + dynamic obstacles | +| Reaction time | Instant | 0.2-0.5s delay | Context-dependent delays | +| Memory | None (goldfish) | Last known position | Full history, learning | +| Debug viz | State labels | Gizmos + logs | Full AI debugger tool | +| Performance | Works at 60 FPS | Time-sliced, LOD | Perfect scaling to 1000+ | +| Edge cases | Basic timeout | Graceful fallbacks | Tested, robust | + +**Example - Stealth Guard**: +- **Quick**: FSM (Patrol, Chase), instant reaction, no memory → Functional but robotic +- **Polished**: BT (Patrol, Hear, Investigate, Alert, Chase), 0.3s delay, remembers last position → Feels good +- **AAA**: BT + Utility, dynamic alertness, learns player patterns, squad coordination → Impressive + +**When to Stop**: +Stop adding AI features when: +1. Playtesters don't notice AI problems +2. AI feels "good enough" for genre (compare to shipped games) +3. Spending more time on AI has diminishing returns vs other features +4. You've hit performance budget (60 FPS with max agents) + +**Red Flag**: Spending 80% of time on 20% improvement (diminishing returns) + + +### Framework 3: Cheating AI vs Fair AI + +**Question**: Should AI have perfect information? + +**Cheating AI (Knows Everything)**: +```csharp +// AI always knows player position +Vector3 playerPos = player.transform.position; +MoveToward(playerPos); +``` + +**Fair AI (Uses Sensors)**: +```csharp +// AI must see/hear player first +if (CanSeePlayer()) +{ + lastKnownPlayerPos = player.transform.position; + MoveToward(lastKnownPlayerPos); +} +else if (CanHearPlayer()) +{ + lastKnownPlayerPos = GetSoundSource(); + Investigate(lastKnownPlayerPos); +} +else +{ + Patrol(); // No info, patrol +} +``` + +**When to Cheat**: +- ✅ Strategy games (fog of war for player, but AI sees all for challenge) +- ✅ Difficulty scaling (higher difficulty = more info) +- ✅ Performance (perfect info is cheaper than raycasts) +- ✅ Predictable challenge (racing game rubberbanding) + +**When to Be Fair**: +- ✅ Stealth games (player expects guards to have limited vision) +- ✅ Immersion-critical (horror, simulation) +- ✅ Competitive (player can outsmart AI) +- ✅ Emergent gameplay (AI mistakes create stories) + +**Hybrid Approach** (Common): +```csharp +public enum Difficulty { Easy, Normal, Hard } +public Difficulty difficulty = Difficulty.Normal; + +void DetectPlayer() +{ + if (difficulty == Difficulty.Easy) + { + // Fair: Requires line of sight + if (CanSeePlayer()) EngagePlayer(); + } + else if (difficulty == Difficulty.Normal) + { + // Slightly cheating: Wider FOV, longer memory + if (CanSeePlayerWithBonusFOV()) EngagePlayer(); + } + else // Hard + { + // More cheating: Always knows player's general area + if (PlayerInSameRoom()) InvestigatePlayerArea(); + } +} +``` + +**Player Perception**: +- Players accept cheating if it makes game more fun/challenging +- Players reject cheating if it feels unfair ("How did they know I was there?!") +- Trick: Cheat subtly (faster reactions, slightly better aim) not obviously (wallhacks) + + +### Framework 4: Time-Slicing Decision + +**Problem**: 100 AI agents × 60 FPS = 6,000 AI updates/second → Frame drops + +**Solution**: Spread AI updates across multiple frames. + +**Pattern 1: Round-Robin (Simple)**: +```csharp +public class AIManager : MonoBehaviour +{ + public List guards = new List(); + private int currentIndex = 0; + private int updatesPerFrame = 10; + + void Update() + { + // Update 10 guards per frame (100 guards = 10 frames to update all) + for (int i = 0; i < updatesPerFrame; i++) + { + if (guards.Count == 0) return; + + guards[currentIndex].AIUpdate(); + currentIndex = (currentIndex + 1) % guards.Count; + } + } +} + +public class GuardAI : MonoBehaviour +{ + // Don't use Update(), called by manager + public void AIUpdate() + { + // AI logic here + } +} +``` + +**Performance Math**: +- 100 guards, 10 per frame → Each guard updates once per 10 frames (6 Hz) +- 6 Hz is enough for patrol/chase (doesn't need 60 Hz) +- Player-visible guards can update more frequently (priority system) + +**Pattern 2: Priority-Based (Advanced)**: +```csharp +public class AIManager : MonoBehaviour +{ + private List guards = new List(); + private float updateBudget = 2f; // 2ms max for AI per frame + + void Update() + { + float startTime = Time.realtimeSinceStartup; + + // Sort by priority (distance to player, alert level, etc.) + guards.Sort((a, b) => b.GetPriority().CompareTo(a.GetPriority())); + + foreach (var guard in guards) + { + guard.AIUpdate(); + + float elapsed = (Time.realtimeSinceStartup - startTime) * 1000f; + if (elapsed >= updateBudget) + { + // Out of time, skip remaining guards this frame + break; + } + } + } +} + +public class GuardAI : MonoBehaviour +{ + public float GetPriority() + { + float distToPlayer = Vector3.Distance(transform.position, player.position); + float basePriority = 1f / Mathf.Max(distToPlayer, 1f); // Closer = higher + + if (isAlerted) basePriority *= 2f; // Alerted guards update more + if (isVisible) basePriority *= 3f; // On-screen guards update most + + return basePriority; + } +} +``` + +**Pattern 3: LOD System (Distance-Based)**: +```csharp +public class AIManager : MonoBehaviour +{ + void Update() + { + foreach (var guard in guards) + { + float dist = Vector3.Distance(guard.transform.position, player.position); + + if (dist < 20f) + { + guard.AIUpdate(); // Full update every frame + } + else if (dist < 50f && Time.frameCount % 3 == 0) + { + guard.AIUpdate(); // Update every 3rd frame + } + else if (dist < 100f && Time.frameCount % 10 == 0) + { + guard.AIUpdate(); // Update every 10th frame + } + // else: Too far, don't update at all + } + } +} +``` + +**When to Use Time-Slicing**: +- ✅ 50+ agents with non-trivial AI +- ✅ Consistent frame rate required (competitive game) +- ✅ AI doesn't need 60 Hz updates (patrol, navigation) + +**When NOT to Use**: +- ❌ <20 agents (not needed) +- ❌ AI requires instant reactions (frame-perfect fighting game) +- ❌ Simple AI (already fast enough) + + +### Framework 5: When to Add Squad Tactics + +**Question**: Do AI agents coordinate or act independently? + +**Independent Agents** (Simple): +- Each guard patrols/chases independently +- No communication +- No role assignment +- Easier to implement, but less interesting + +**Squad Coordination** (Complex): +- Guards share information ("I heard something at X") +- Role assignment (leader, flanker, suppressor) +- Coordinated attacks (pincer movement) +- More impressive, but much harder + +**Decision**: + +| Factor | Independent | Squad | +|--------|-------------|-------| +| Time budget | < 10 hours | 20-40 hours | +| Number of agents | 1-3 nearby | 4+ in group | +| Player engagement | Brief encounters | Extended combat | +| Genre | Stealth, survival | Tactical shooter, RTS | + +**Incremental Approach** (Recommended): +1. **Phase 1**: Get individual AI working (patrol, chase, attack) +2. **Phase 2**: Add simple communication (broadcast alerts) +3. **Phase 3**: Add role assignment (first to see player becomes leader) +4. **Phase 4**: Add coordinated tactics (flanking, suppression) + +**Simple Squad Communication**: +```csharp +public class GuardAI : MonoBehaviour +{ + public static event Action OnSuspiciousSound; + + void HearSound(Vector3 soundPos) + { + // Broadcast to all guards + OnSuspiciousSound?.Invoke(soundPos); + } + + void OnEnable() + { + OnSuspiciousSound += ReactToSound; + } + + void OnDisable() + { + OnSuspiciousSound -= ReactToSound; + } + + void ReactToSound(Vector3 soundPos) + { + float dist = Vector3.Distance(transform.position, soundPos); + if (dist < 20f) + { + investigatePosition = soundPos; + currentState = State.Investigate; + } + } +} +``` + + +## Implementation Patterns + +### Pattern 1: Behavior Tree with Time-Slicing + +**Complete guard AI** with BT architecture and performance optimizations: + +```csharp +using UnityEngine; +using UnityEngine.AI; + +// Behavior Tree Nodes +public abstract class BTNode +{ + public abstract BTNodeState Tick(); +} + +public enum BTNodeState { Success, Failure, Running } + +public class Selector : BTNode +{ + private BTNode[] children; + + public Selector(params BTNode[] children) { this.children = children; } + + public override BTNodeState Tick() + { + foreach (var child in children) + { + BTNodeState state = child.Tick(); + if (state != BTNodeState.Failure) + return state; // Return on first success or running + } + return BTNodeState.Failure; + } +} + +public class Sequence : BTNode +{ + private BTNode[] children; + private int currentChild = 0; + + public Sequence(params BTNode[] children) { this.children = children; } + + public override BTNodeState Tick() + { + for (int i = currentChild; i < children.Length; i++) + { + BTNodeState state = children[i].Tick(); + + if (state == BTNodeState.Failure) + { + currentChild = 0; + return BTNodeState.Failure; + } + + if (state == BTNodeState.Running) + { + currentChild = i; + return BTNodeState.Running; + } + } + + currentChild = 0; + return BTNodeState.Success; + } +} + +public class Condition : BTNode +{ + private System.Func condition; + + public Condition(System.Func condition) { this.condition = condition; } + + public override BTNodeState Tick() + { + return condition() ? BTNodeState.Success : BTNodeState.Failure; + } +} + +public class Action : BTNode +{ + private System.Func action; + + public Action(System.Func action) { this.action = action; } + + public override BTNodeState Tick() + { + return action(); + } +} + +// Guard AI Implementation +public class GuardAI : MonoBehaviour +{ + [Header("Components")] + private NavMeshAgent agent; + private Transform player; + + [Header("Configuration")] + public Transform[] waypoints; + public float sightRange = 15f; + public float hearingRange = 20f; + public float attackRange = 2f; + + [Header("State")] + private int currentWaypoint = 0; + private Vector3 lastKnownPlayerPos; + private Vector3 investigatePosition; + private float investigateTimer = 0f; + private bool hasHeardSound = false; + + private BTNode behaviorTree; + + void Start() + { + agent = GetComponent(); + player = GameObject.FindGameObjectWithTag("Player").transform; + + // Build behavior tree + behaviorTree = new Selector( + // Priority 1: Combat + new Sequence( + new Condition(() => CanSeePlayer()), + new Selector( + new Sequence( + new Condition(() => InAttackRange()), + new Action(() => Attack()) + ), + new Action(() => Chase()) + ) + ), + + // Priority 2: Investigate sounds + new Sequence( + new Condition(() => hasHeardSound), + new Action(() => Investigate()) + ), + + // Priority 3: Patrol + new Action(() => Patrol()) + ); + } + + // Called by AIManager (time-sliced) + public void AIUpdate() + { + behaviorTree.Tick(); + } + + // === Behaviors === + + BTNodeState Patrol() + { + if (waypoints.Length == 0) return BTNodeState.Failure; + + if (!agent.hasPath || agent.remainingDistance < 0.5f) + { + agent.SetDestination(waypoints[currentWaypoint].position); + currentWaypoint = (currentWaypoint + 1) % waypoints.Length; + } + + return BTNodeState.Running; + } + + BTNodeState Investigate() + { + agent.SetDestination(investigatePosition); + + if (agent.remainingDistance < 1f) + { + investigateTimer += Time.deltaTime; + + if (investigateTimer > 3f) + { + hasHeardSound = false; + investigateTimer = 0f; + return BTNodeState.Success; + } + } + + return BTNodeState.Running; + } + + BTNodeState Chase() + { + lastKnownPlayerPos = player.position; + agent.SetDestination(lastKnownPlayerPos); + return BTNodeState.Running; + } + + BTNodeState Attack() + { + agent.isStopped = true; + transform.LookAt(player); + Debug.Log("Attacking!"); + return BTNodeState.Success; + } + + // === Conditions === + + bool CanSeePlayer() + { + float dist = Vector3.Distance(transform.position, player.position); + if (dist > sightRange) return false; + + Vector3 dirToPlayer = (player.position - transform.position).normalized; + float angle = Vector3.Angle(transform.forward, dirToPlayer); + + if (angle > 60f) return false; // 120° FOV + + RaycastHit hit; + if (Physics.Raycast(transform.position + Vector3.up, dirToPlayer, out hit, sightRange)) + { + return hit.transform == player; + } + + return false; + } + + bool InAttackRange() + { + return Vector3.Distance(transform.position, player.position) < attackRange; + } + + public void OnHearSound(Vector3 soundPos) + { + float dist = Vector3.Distance(transform.position, soundPos); + if (dist < hearingRange) + { + investigatePosition = soundPos; + hasHeardSound = true; + } + } + + // === Debug Visualization === + + void OnDrawGizmos() + { + // Sight range + Gizmos.color = Color.yellow; + Gizmos.DrawWireSphere(transform.position, sightRange); + + // FOV cone + Vector3 forward = transform.forward * sightRange; + Vector3 right = Quaternion.Euler(0, 60, 0) * forward; + Vector3 left = Quaternion.Euler(0, -60, 0) * forward; + + Gizmos.DrawLine(transform.position, transform.position + right); + Gizmos.DrawLine(transform.position, transform.position + left); + + // Investigate target + if (hasHeardSound) + { + Gizmos.color = Color.red; + Gizmos.DrawLine(transform.position, investigatePosition); + Gizmos.DrawWireSphere(investigatePosition, 0.5f); + } + + // State label + #if UNITY_EDITOR + UnityEditor.Handles.Label( + transform.position + Vector3.up * 2, + $"State: {GetCurrentState()}" + ); + #endif + } + + string GetCurrentState() + { + if (CanSeePlayer()) return InAttackRange() ? "Attack" : "Chase"; + if (hasHeardSound) return "Investigate"; + return "Patrol"; + } +} + +// AI Manager (Time-Slicing) +public class AIManager : MonoBehaviour +{ + public List guards = new List(); + private int currentIndex = 0; + public int updatesPerFrame = 10; + + void Update() + { + for (int i = 0; i < updatesPerFrame && guards.Count > 0; i++) + { + guards[currentIndex].AIUpdate(); + currentIndex = (currentIndex + 1) % guards.Count; + } + } + + void OnGUI() + { + GUI.Label(new Rect(10, 10, 200, 20), $"Guards: {guards.Count}"); + GUI.Label(new Rect(10, 30, 200, 20), $"FPS: {1f / Time.deltaTime:F1}"); + } +} +``` + +**Key Features**: +- ✅ Behavior Tree (hierarchical, composable) +- ✅ Time-slicing via AIManager +- ✅ NavMesh pathfinding +- ✅ Debug visualization (FOV, state, target) +- ✅ Proper conditions (FOV cone, raycast) +- ✅ Handles 100+ guards at 60 FPS + + +### Pattern 2: Utility AI for Context-Dependent Behavior + +**When**: AI needs to balance multiple priorities based on context (health, ammo, distance). + +```csharp +using UnityEngine; +using System.Collections.Generic; +using System.Linq; + +public class UtilityAI : MonoBehaviour +{ + [Header("State")] + private float health = 100f; + private float ammo = 30f; + private Transform player; + + [Header("Configuration")] + public float maxHealth = 100f; + public float maxAmmo = 30f; + + // Actions with utility calculations + private List actions = new List(); + + void Start() + { + player = GameObject.FindGameObjectWithTag("Player").transform; + + // Define actions + actions.Add(new AIAction("Patrol", CalculatePatrolUtility, Patrol)); + actions.Add(new AIAction("Combat", CalculateCombatUtility, Combat)); + actions.Add(new AIAction("Heal", CalculateHealUtility, Heal)); + actions.Add(new AIAction("Reload", CalculateReloadUtility, Reload)); + actions.Add(new AIAction("Flee", CalculateFleeUtility, Flee)); + actions.Add(new AIAction("FindAmmo", CalculateFindAmmoUtility, FindAmmo)); + } + + void Update() + { + // Evaluate all actions and pick best + AIAction bestAction = actions.OrderByDescending(a => a.CalculateUtility()).First(); + bestAction.Execute(); + } + + // === Utility Calculations (0-1 scores) === + + float CalculatePatrolUtility() + { + // Low utility if anything else is urgent + float distToPlayer = Vector3.Distance(transform.position, player.position); + if (distToPlayer < 30f) return 0f; // Player nearby + + // High utility if healthy, has ammo, and no threats + return 0.3f; + } + + float CalculateCombatUtility() + { + float distToPlayer = Vector3.Distance(transform.position, player.position); + float healthRatio = health / maxHealth; + float ammoRatio = ammo / maxAmmo; + + // Only fight if: Close to player, healthy, and have ammo + float distance Score = Mathf.Clamp01(1f - distToPlayer / 50f); // Closer = higher + float healthScore = healthRatio; // Higher health = higher + float ammoScore = ammoRatio; // More ammo = higher + + // Weighted combination + return distanceScore * 0.4f + healthScore * 0.3f + ammoScore * 0.3f; + } + + float CalculateHealUtility() + { + float healthRatio = health / maxHealth; + + // Quadratic curve: Low health = VERY urgent + return (1f - healthRatio) * (1f - healthRatio); + } + + float CalculateReloadUtility() + { + float ammoRatio = ammo / maxAmmo; + + // Linear: Low ammo = reload + return (1f - ammoRatio); + } + + float CalculateFleeUtility() + { + float distToPlayer = Vector3.Distance(transform.position, player.position); + float healthRatio = health / maxHealth; + float ammoRatio = ammo / maxAmmo; + + // Flee if: Low health, no ammo, and player is close + float lowHealthScore = (1f - healthRatio); + float noAmmoScore = (1f - ammoRatio); + float playerCloseScore = Mathf.Clamp01(1f - distToPlayer / 20f); + + return lowHealthScore * 0.5f + noAmmoScore * 0.2f + playerCloseScore * 0.3f; + } + + float CalculateFindAmmoUtility() + { + float ammoRatio = ammo / maxAmmo; + // Only urgent if very low ammo + return ammoRatio < 0.2f ? 0.8f : 0f; + } + + // === Action Implementations === + + void Patrol() { Debug.Log("Patrolling..."); } + void Combat() { Debug.Log("Combat!"); ammo -= Time.deltaTime * 5f; } + void Heal() { Debug.Log("Healing..."); health = Mathf.Min(health + Time.deltaTime * 10f, maxHealth); } + void Reload() { Debug.Log("Reloading..."); ammo = maxAmmo; } + void Flee() { Debug.Log("Fleeing!"); } + void FindAmmo() { Debug.Log("Finding ammo..."); ammo = maxAmmo; } + + // === Debug === + + void OnGUI() + { + int y = 10; + foreach (var action in actions.OrderByDescending(a => a.CalculateUtility())) + { + float utility = action.CalculateUtility(); + GUI.Label(new Rect(10, y, 300, 20), $"{action.Name}: {utility:F2}"); + y += 20; + } + + GUI.Label(new Rect(10, y + 10, 200, 20), $"Health: {health:F0}/{maxHealth}"); + GUI.Label(new Rect(10, y + 30, 200, 20), $"Ammo: {ammo:F0}/{maxAmmo}"); + } +} + +// Helper class +public class AIAction +{ + public string Name; + private System.Func calculateUtility; + private System.Action execute; + + public AIAction(string name, System.Func calc, System.Action exec) + { + Name = name; + calculateUtility = calc; + execute = exec; + } + + public float CalculateUtility() => calculateUtility(); + public void Execute() => execute(); +} +``` + +**When to Use Utility AI**: +- Multiple context-dependent priorities (health, ammo, distance, threat level) +- Want smooth transitions (not binary state switches) +- Emergent behavior from scoring (interesting decision-making) + + +### Pattern 3: Sensor System (Decoupled from Decision-Making) + +**Problem**: Raycasting every frame is expensive. Cache sensor data. + +```csharp +public class SensorSystem : MonoBehaviour +{ + [Header("Configuration")] + public float visionRange = 15f; + public float visionAngle = 120f; + public float hearingRange = 20f; + public float sensorUpdateRate = 5f; // Hz (updates per second) + + [Header("Sensor Data (Cached)")] + public bool canSeePlayer = false; + public bool canHearPlayer = false; + public Vector3 lastKnownPlayerPosition = Vector3.zero; + public float timeSinceLastSeen = Mathf.Infinity; + + private Transform player; + private float nextSensorUpdate = 0f; + + void Start() + { + player = GameObject.FindGameObjectWithTag("Player").transform; + } + + void Update() + { + // Update sensors at fixed rate (not every frame) + if (Time.time >= nextSensorUpdate) + { + UpdateVision(); + UpdateHearing(); + + nextSensorUpdate = Time.time + (1f / sensorUpdateRate); + } + + // Track time since last seen + if (!canSeePlayer) + { + timeSinceLastSeen += Time.deltaTime; + } + else + { + timeSinceLastSeen = 0f; + } + } + + void UpdateVision() + { + if (player == null) + { + canSeePlayer = false; + return; + } + + float distToPlayer = Vector3.Distance(transform.position, player.position); + + // Distance check + if (distToPlayer > visionRange) + { + canSeePlayer = false; + return; + } + + // Angle check (FOV) + Vector3 dirToPlayer = (player.position - transform.position).normalized; + float angle = Vector3.Angle(transform.forward, dirToPlayer); + + if (angle > visionAngle / 2f) + { + canSeePlayer = false; + return; + } + + // Raycast (line of sight) + RaycastHit hit; + if (Physics.Raycast(transform.position + Vector3.up, dirToPlayer, out hit, visionRange)) + { + if (hit.transform == player) + { + canSeePlayer = true; + lastKnownPlayerPosition = player.position; + } + else + { + canSeePlayer = false; + } + } + } + + void UpdateHearing() + { + if (player == null) + { + canHearPlayer = false; + return; + } + + // Simple distance-based hearing + // In production, you'd check player's noise level + float distToPlayer = Vector3.Distance(transform.position, player.position); + + // Assume player makes noise if moving + bool playerMoving = player.GetComponent().velocity.magnitude > 0.1f; + + canHearPlayer = playerMoving && distToPlayer < hearingRange; + + if (canHearPlayer) + { + lastKnownPlayerPosition = player.position; + } + } + + void OnDrawGizmos() + { + // Vision range + Gizmos.color = canSeePlayer ? Color.red : Color.yellow; + Gizmos.DrawWireSphere(transform.position, visionRange); + + // FOV cone + Vector3 forward = transform.forward * visionRange; + Vector3 right = Quaternion.Euler(0, visionAngle / 2f, 0) * forward; + Vector3 left = Quaternion.Euler(0, -visionAngle / 2f, 0) * forward; + + Gizmos.DrawLine(transform.position, transform.position + right); + Gizmos.DrawLine(transform.position, transform.position + left); + + // Hearing range + Gizmos.color = canHearPlayer ? Color.blue : Color.cyan; + Gizmos.DrawWireSphere(transform.position, hearingRange); + + // Last known position + if (lastKnownPlayerPosition != Vector3.zero) + { + Gizmos.color = Color.magenta; + Gizmos.DrawSphere(lastKnownPlayerPosition, 0.5f); + } + } +} + +// Guard AI uses sensor data (no raycasting in AI logic) +public class GuardAIWithSensors : MonoBehaviour +{ + private SensorSystem sensors; + + void Start() + { + sensors = GetComponent(); + } + + void AIUpdate() + { + if (sensors.canSeePlayer) + { + Chase(sensors.lastKnownPlayerPosition); + } + else if (sensors.canHearPlayer) + { + Investigate(sensors.lastKnownPlayerPosition); + } + else if (sensors.timeSinceLastSeen < 5f) + { + SearchLastKnownPosition(); + } + else + { + Patrol(); + } + } +} +``` + +**Benefits**: +- ✅ Sensors update at 5 Hz instead of 60 Hz (12x fewer raycasts) +- ✅ AI logic is decoupled (easier to test) +- ✅ Cached data (AI can check `canSeePlayer` without raycasting) +- ✅ Easy to tune (change update rate independently) + + +### Pattern 4: Memory System (Last Known Position) + +**Problem**: AI forgets player instantly when losing sight (goldfish memory). + +**Solution**: Remember last position, search area before giving up. + +```csharp +public class AIMemory : MonoBehaviour +{ + [Header("Memory Configuration")] + public float memoryDuration = 10f; // Seconds to remember + public float searchRadius = 5f; + + [Header("Memory Data")] + private Vector3 lastKnownPlayerPosition = Vector3.zero; + private float timeSinceLastSeen = Mathf.Infinity; + private bool hasMemory = false; + + public void UpdateMemory(bool canSeePlayer, Vector3 playerPosition) + { + if (canSeePlayer) + { + lastKnownPlayerPosition = playerPosition; + timeSinceLastSeen = 0f; + hasMemory = true; + } + else + { + timeSinceLastSeen += Time.deltaTime; + + // Forget after duration + if (timeSinceLastSeen > memoryDuration) + { + hasMemory = false; + } + } + } + + public Vector3 GetSearchPosition() + { + // Add randomness to search (not exact position) + Vector2 randomOffset = Random.insideUnitCircle * searchRadius; + return lastKnownPlayerPosition + new Vector3(randomOffset.x, 0, randomOffset.y); + } + + public bool ShouldSearch() + { + return hasMemory && timeSinceLastSeen < memoryDuration; + } +} + +// Guard AI with memory +public class GuardAIWithMemory : MonoBehaviour +{ + private AIMemory memory; + private SensorSystem sensors; + private NavMeshAgent agent; + + private enum State { Patrol, Chase, Search } + private State currentState = State.Patrol; + private Vector3 searchTarget; + + void Start() + { + memory = GetComponent(); + sensors = GetComponent(); + agent = GetComponent(); + } + + void AIUpdate() + { + memory.UpdateMemory(sensors.canSeePlayer, sensors.lastKnownPlayerPosition); + + if (sensors.canSeePlayer) + { + currentState = State.Chase; + agent.SetDestination(sensors.lastKnownPlayerPosition); + } + else if (memory.ShouldSearch()) + { + if (currentState != State.Search) + { + // Enter search state + currentState = State.Search; + searchTarget = memory.GetSearchPosition(); + agent.SetDestination(searchTarget); + } + + // Check if reached search target + if (agent.remainingDistance < 1f) + { + // Pick new search position + searchTarget = memory.GetSearchPosition(); + agent.SetDestination(searchTarget); + } + } + else + { + currentState = State.Patrol; + Patrol(); + } + } +} +``` + +**Believability Impact**: +- ❌ No memory: Guard loses player, instantly returns to patrol (robotic) +- ✅ With memory: Guard searches last known area for 10 seconds (believable) + + +### Pattern 5: Reaction Delay (Human-Like Behavior) + +**Problem**: AI reacts instantly (0ms) → feels robotic. + +**Solution**: Add 200-500ms delay for human-like reactions. + +```csharp +public class GuardAIWithDelay : MonoBehaviour +{ + [Header("Reaction Configuration")] + public float minReactionTime = 0.2f; + public float maxReactionTime = 0.5f; + + private bool isReacting = false; + private float reactionEndTime = 0f; + + void AIUpdate() + { + if (sensors.canSeePlayer && !isReacting) + { + // Start reaction delay + StartReaction(); + } + + if (isReacting) + { + if (Time.time >= reactionEndTime) + { + // Reaction complete, start chase + isReacting = false; + currentState = State.Chase; + } + else + { + // Still reacting (look at player, but don't chase yet) + transform.LookAt(player.position); + } + } + } + + void StartReaction() + { + isReacting = true; + float reactionDelay = Random.Range(minReactionTime, maxReactionTime); + reactionEndTime = Time.time + reactionDelay; + + // Optional: Play "alert" animation + // animator.SetTrigger("Alert"); + } +} +``` + +**Tuning Reaction Times**: +- **Zombie/Slow Enemy**: 0.5-1.0s (slow to react) +- **Guard/Soldier**: 0.2-0.5s (human reaction) +- **Elite/Boss**: 0.1-0.2s (fast reflexes) +- **Turret/Robot**: 0.0s (instant, robotic feel is appropriate) + + +## Common Pitfalls + +### Pitfall 1: FSM Spaghetti Code (Too Many Transitions) + +**The Mistake**: +```csharp +// ❌ FSM with 6 states and 20+ transitions +public enum State { Idle, Patrol, Investigate, Alert, Chase, Attack, Flee, Search } + +void Update() +{ + switch (currentState) + { + case State.Patrol: + if (HearSound()) currentState = State.Investigate; + if (SeePlayer()) currentState = State.Alert; + if (LowHealth()) currentState = State.Flee; + if (NoAmmo()) currentState = State.Search; + break; + + case State.Investigate: + if (SeePlayer()) currentState = State.Alert; + if (FoundNothing()) currentState = State.Patrol; + if (LowHealth()) currentState = State.Flee; + // ... 10+ more transitions + break; + + // ... 6 more states with similar complexity + } +} +``` + +**Why This Fails**: +- **State explosion**: 6 states × 5 transitions = 30+ lines of transition logic +- **Duplicate logic**: "if (SeePlayer())" appears in 4 states +- **Hard to modify**: Adding new state requires updating 5+ other states +- **Bug-prone**: Easy to miss a transition edge case + +**The Fix**: Use Behavior Tree +```csharp +// ✅ Behavior Tree - Same logic, hierarchical +behaviorTree = new Selector( + new Sequence( + new Condition(() => LowHealth()), + new Action(() => Flee()) + ), + new Sequence( + new Condition(() => NoAmmo()), + new Action(() => SearchForAmmo()) + ), + new Sequence( + new Condition(() => SeePlayer()), + new Selector( + new Sequence( + new Condition(() => InRange()), + new Action(() => Attack()) + ), + new Action(() => Chase()) + ) + ), + new Sequence( + new Condition(() => HearSound()), + new Action(() => Investigate()) + ), + new Action(() => Patrol()) +); +``` + +**Red Flag**: If drawing FSM diagram results in spaghetti (20+ arrows), switch to BT. + + +### Pitfall 2: Updating All AI Every Frame (Performance Killer) + +**The Mistake**: +```csharp +// ❌ 100 guards × 60 FPS = 6,000 AI updates/sec +void Update() +{ + // Complex AI logic + // Raycasts, pathfinding, decision-making +} +``` + +**Why This Fails**: +- 100 agents × 0.5ms = 50ms per frame (over 16.67ms budget!) +- Linear scaling: 200 agents = frame drops guaranteed +- Unnecessary: Patrol AI doesn't need 60 Hz updates + +**The Fix**: Time-Slicing +```csharp +// ✅ AIManager updates 10 guards per frame +public class AIManager : MonoBehaviour +{ + private List guards; + private int currentIndex = 0; + + void Update() + { + // Update 10 guards per frame (100 guards = 10 frames = 6 Hz per guard) + for (int i = 0; i < 10; i++) + { + guards[currentIndex].AIUpdate(); + currentIndex = (currentIndex + 1) % guards.Count; + } + } +} +``` + +**Performance Math**: +- Before: 100 guards × 0.5ms × 60 FPS = 3000ms/sec (impossible!) +- After: 10 guards × 0.5ms × 60 FPS = 300ms/sec (10% CPU) + +**Red Flag**: FPS drops when adding more AI agents → Need time-slicing. + + +### Pitfall 3: No Debug Visualization (Black Box AI) + +**The Mistake**: +```csharp +// ❌ No way to see what AI is doing +public class GuardAI : MonoBehaviour +{ + private State currentState; + + void Update() + { + // Complex logic, but invisible + } +} +``` + +**Why This Fails**: +- Bug: "Guard won't chase player" → 30 minutes adding Debug.Log everywhere +- No way to see FOV, path, state transitions +- Designers can't tune behavior visually + +**The Fix**: Gizmos and Labels +```csharp +// ✅ Visualize everything +void OnDrawGizmos() +{ + // State label + #if UNITY_EDITOR + UnityEditor.Handles.Label(transform.position + Vector3.up * 2, currentState.ToString()); + #endif + + // FOV cone + Gizmos.color = Color.yellow; + Vector3 forward = transform.forward * sightRange; + Vector3 right = Quaternion.Euler(0, 60, 0) * forward; + Vector3 left = Quaternion.Euler(0, -60, 0) * forward; + Gizmos.DrawLine(transform.position, transform.position + forward); + Gizmos.DrawLine(transform.position, transform.position + right); + Gizmos.DrawLine(transform.position, transform.position + left); + + // Current path + if (agent.hasPath) + { + Gizmos.color = Color.green; + Vector3[] path = agent.path.corners; + for (int i = 0; i < path.Length - 1; i++) + { + Gizmos.DrawLine(path[i], path[i + 1]); + } + } + + // Target position + if (targetPosition != Vector3.zero) + { + Gizmos.color = Color.red; + Gizmos.DrawSphere(targetPosition, 0.5f); + } +} +``` + +**Impact**: Debugging time reduced from hours to minutes. + + +### Pitfall 4: No Pathfinding Failure Handling + +**The Mistake**: +```csharp +// ❌ Assumes pathfinding always succeeds +void Chase() +{ + agent.SetDestination(player.position); + // What if player is in unreachable area? +} +``` + +**Why This Fails**: +- Player enters vent/locked room → Guard can't reach +- Guard keeps trying → walks into wall forever +- AI looks broken + +**Real-World Scenario**: +- Stealth game: Player climbs ladder +- Guard can't path to ladder → stuck at bottom +- Ruins immersion + +**The Fix**: Timeout and Fallback +```csharp +// ✅ Handle pathfinding failures +private float chaseStartTime = 0f; +private const float CHASE_TIMEOUT = 5f; + +void Chase() +{ + if (currentState != State.Chase) + { + chaseStartTime = Time.time; + currentState = State.Chase; + } + + // Check if path exists + NavMeshPath path = new NavMeshPath(); + bool hasPath = NavMesh.CalculatePath( + transform.position, + player.position, + NavMesh.AllAreas, + path + ); + + if (hasPath && path.status == NavMeshPathStatus.PathComplete) + { + agent.SetPath(path); + } + else + { + // No path - give up chase + Debug.LogWarning("No path to player, returning to patrol"); + currentState = State.Patrol; + return; + } + + // Timeout if chase lasts too long + if (Time.time - chaseStartTime > CHASE_TIMEOUT) + { + Debug.LogWarning("Chase timeout, player unreachable"); + currentState = State.Search; // Search last known area + } +} +``` + +**Edge Cases to Handle**: +- ✅ No path to target (unreachable) +- ✅ Path blocked mid-chase (dynamic obstacle) +- ✅ Timeout (AI stuck in state too long) +- ✅ NavMesh missing (level not baked) + + +### Pitfall 5: Too Predictable or Too Random + +**The Mistake (Too Predictable)**: +```csharp +// ❌ Guards always patrol in exact 10-second loops +void Patrol() +{ + if (Time.time - lastWaypointTime > 10f) + { + NextWaypoint(); + } +} +``` + +**The Mistake (Too Random)**: +```csharp +// ❌ Guards pick completely random actions +void Update() +{ + int action = Random.Range(0, 3); + if (action == 0) Patrol(); + else if (action == 1) Idle(); + else Investigate(RandomPosition()); +} +``` + +**Why Both Fail**: +- Too predictable: Player learns pattern, exploits it +- Too random: No cause-and-effect, feels broken + +**The Fix**: Controlled Randomness +```csharp +// ✅ Predictable core behavior + random variance +void Patrol() +{ + if (!agent.hasPath || agent.remainingDistance < 0.5f) + { + // Predictable: Go to next waypoint + agent.SetDestination(waypoints[currentWaypoint].position); + currentWaypoint = (currentWaypoint + 1) % waypoints.Length; + + // Random: Vary pause duration + float pauseDuration = Random.Range(2f, 5f); + StartCoroutine(PauseAtWaypoint(pauseDuration)); + } +} + +IEnumerator PauseAtWaypoint(float duration) +{ + agent.isStopped = true; + + // Random: Sometimes look around + if (Random.value > 0.5f) + { + PlayAnimation("LookAround"); + } + + yield return new WaitForSeconds(duration); + agent.isStopped = false; +} +``` + +**Tuning**: +- **Core behavior**: Predictable (patrol waypoints in order) +- **Timing**: Randomized (pause 2-5 seconds, not always 3) +- **Personality**: Slight variance (some guards more alert/lazy) + + +### Pitfall 6: No Animation Integration + +**The Mistake**: +```csharp +// ❌ AI logic with no animation +void Chase() +{ + agent.SetDestination(player.position); + // Agent slides around, no running animation +} +``` + +**Why This Fails**: +- AI works logically, but looks broken visually +- No feedback to player about AI state + +**The Fix**: Sync Animations with AI State +```csharp +// ✅ Animation integration +public class GuardAI : MonoBehaviour +{ + private Animator animator; + private NavMeshAgent agent; + + void Start() + { + animator = GetComponent(); + agent = GetComponent(); + } + + void Update() + { + // Sync animation with movement speed + float speed = agent.velocity.magnitude; + animator.SetFloat("Speed", speed); + + // Sync animation with state + animator.SetBool("IsAlerted", currentState == State.Chase || currentState == State.Attack); + animator.SetBool("IsAttacking", currentState == State.Attack); + } + + void Chase() + { + agent.SetDestination(player.position); + + // Trigger alert animation on state enter + if (previousState != State.Chase) + { + animator.SetTrigger("Alert"); + } + } + + void Attack() + { + agent.isStopped = true; + animator.SetTrigger("Attack"); + } +} +``` + +**Animation Parameters**: +- **Speed** (float): Blend idle/walk/run animations +- **IsAlerted** (bool): Change stance (relaxed vs combat-ready) +- **IsAttacking** (bool): Play attack animation +- **Alert** (trigger): Play "heard something" reaction animation + + +### Pitfall 7: Inefficient Player Detection + +**The Mistake**: +```csharp +// ❌ Every guard raycasts to player every frame +void Update() +{ + RaycastHit hit; + if (Physics.Raycast(transform.position, player.position - transform.position, out hit)) + { + if (hit.transform == player) + { + Chase(); + } + } +} +``` + +**Why This Fails**: +- 100 guards × 60 FPS = 6,000 raycasts per second +- Raycast is expensive (~0.1-0.5ms each) +- 6,000 × 0.2ms = 1,200ms per frame (impossible!) + +**The Fix 1**: Angle Check Before Raycast +```csharp +// ✅ Check angle first (cheap), only raycast if in FOV +bool CanSeePlayer() +{ + float dist = Vector3.Distance(transform.position, player.position); + if (dist > sightRange) return false; + + Vector3 dirToPlayer = (player.position - transform.position).normalized; + float angle = Vector3.Angle(transform.forward, dirToPlayer); + + if (angle > 60f) return false; // Outside FOV + + // Only raycast if passed cheap checks + RaycastHit hit; + if (Physics.Raycast(transform.position + Vector3.up, dirToPlayer, out hit, sightRange)) + { + return hit.transform == player; + } + + return false; +} +``` + +**The Fix 2**: Time-Slice Sensor Updates +```csharp +// ✅ Update sensors at 5 Hz instead of 60 Hz +private float nextSensorUpdate = 0f; +private float sensorUpdateRate = 5f; // Hz + +void Update() +{ + if (Time.time >= nextSensorUpdate) + { + canSeePlayer = CanSeePlayer(); // Raycast here + nextSensorUpdate = Time.time + (1f / sensorUpdateRate); + } + + // AI logic uses cached canSeePlayer +} +``` + +**Performance Impact**: +- Before: 100 guards × 60 FPS = 6,000 raycasts/sec +- After: 100 guards × 5 Hz = 500 raycasts/sec (12x improvement!) + + +## Real-World Examples + +### Example 1: Halo - Behavior Trees for Marines + +**Architecture**: Behavior Tree (hierarchical combat behaviors) + +**Conceptual Structure**: +``` +Selector (Pick highest priority) +├─ Sequence (Grenade throw) +│ ├─ Condition: Has grenade +│ ├─ Condition: Enemy in grenade range +│ └─ Action: Throw grenade +│ +├─ Sequence (Take cover) +│ ├─ Condition: Under fire +│ ├─ Condition: Cover available nearby +│ └─ Action: Move to cover +│ +├─ Sequence (Combat) +│ ├─ Condition: Enemy in sight +│ └─ Selector (Combat type) +│ ├─ Sequence (Melee if close) +│ │ ├─ Condition: Enemy within 3m +│ │ └─ Action: Melee attack +│ └─ Action: Ranged attack +│ +└─ Action: Follow player (default) +``` + +**Key Features**: +- Hierarchical: Combat → Melee vs Ranged +- Priority-based: Grenade > Cover > Combat > Follow +- Composable: "Take cover" subtree reused across enemy types +- Extensible: Easy to add new behaviors without breaking existing + +**Why BT Over FSM**: +- Halo marines have 10+ behaviors (FSM would be spaghetti) +- Behaviors compose (cover + shoot, not separate states) +- Designers can modify trees without code + + +### Example 2: F.E.A.R. - GOAP for Soldiers + +**Architecture**: Goal-Oriented Action Planning (dynamic tactics) + +**Available Actions**: +```csharp +// Soldier has 12 possible actions +List actions = new List +{ + // Movement + new Action("MoveToCover", cost: 2, preconditions: {coverAvailable: true}, effects: {inCover: true}), + new Action("FlankPlayer", cost: 5, preconditions: {}, effects: {hasFlankedPlayer: true}), + + // Combat + new Action("ShootAtPlayer", cost: 1, preconditions: {hasWeapon: true, hasAmmo: true}, effects: {playerSuppressed: true}), + new Action("ThrowGrenade", cost: 3, preconditions: {hasGrenade: true}, effects: {playerFlushedFromCover: true}), + + // Support + new Action("CallForBackup", cost: 4, preconditions: {}, effects: {backupCalled: true}), + new Action("SuppressFire", cost: 2, preconditions: {hasAmmo: true}, effects: {playerSuppressed: true}), +}; + +// Goal: Kill player +Dictionary goal = new Dictionary +{ + { "playerDead", true } +}; + +// Planner dynamically creates: MoveToCover → SuppressFire → FlankPlayer → ShootAtPlayer +``` + +**Why GOAP**: +- **Emergent tactics**: Soldiers figure out flanking without scripted behavior +- **Adaptable**: If cover destroyed, re-plan (different action sequence) +- **Looks smart**: Players see soldiers coordinating (even though it's just planning) + +**Famous F.E.A.R. Moment**: +- Player hides behind cover +- Soldier 1: Suppresses player (keeps them pinned) +- Soldier 2: Flanks to side +- Soldier 3: Throws grenade to flush player out +- **All emergent from GOAP** (not scripted!) + +**Implementation Complexity**: High (2-4 weeks for planner), but pays off in believability + + +### Example 3: The Sims - Utility AI for Needs + +**Architecture**: Utility AI (context-dependent need prioritization) + +**Need Scoring**: +```csharp +// Sim has multiple needs (0-100 scale) +public class SimNeeds +{ + public float hunger = 50f; + public float energy = 50f; + public float social = 50f; + public float hygiene = 50f; + public float bladder = 50f; + public float fun = 50f; +} + +// Calculate utility for each action +float CalculateEatUtility() +{ + // Quadratic: Low hunger = VERY urgent + float hungerScore = (100f - hunger) / 100f; + return hungerScore * hungerScore; +} + +float CalculateSleepUtility() +{ + float energyScore = (100f - energy) / 100f; + return energyScore * energyScore; +} + +float CalculateSocializeUtility() +{ + float socialScore = (100f - social) / 100f; + // Linear: Less urgent than hunger/sleep + return socialScore * 0.5f; +} + +void Update() +{ + // Pick highest-scoring action + float eatScore = CalculateEatUtility(); + float sleepScore = CalculateSleepUtility(); + float socializeScore = CalculateSocializeUtility(); + + float maxScore = Mathf.Max(eatScore, sleepScore, socializeScore); + + if (maxScore == eatScore) GoEat(); + else if (maxScore == sleepScore) GoSleep(); + else GoSocialize(); +} +``` + +**Why Utility AI**: +- **Emergent behavior**: Sims don't just follow scripts, they respond to context +- **Smooth transitions**: No hard state switches (gradual priority shifts) +- **Tunable**: Designers adjust curves to change personality (lazy sim → low energy threshold) + +**Personality Variance**: +```csharp +// Lazy sim: Lower sleep threshold +float CalculateSleepUtility() +{ + float energyScore = (100f - energy) / 100f; + return energyScore * energyScore * 1.5f; // 50% more likely to sleep +} + +// Social sim: Higher social threshold +float CalculateSocializeUtility() +{ + float socialScore = (100f - social) / 100f; + return socialScore * 1.5f; // 50% more likely to socialize +} +``` + + +### Example 4: Unity NavMesh Example (AAA Quality) + +**Real Implementation** from Unity demo projects: + +```csharp +using UnityEngine; +using UnityEngine.AI; + +public class EnemyAI : MonoBehaviour +{ + [Header("Components")] + private NavMeshAgent agent; + private Animator animator; + private Health health; + + [Header("Configuration")] + public Transform[] patrolWaypoints; + public float detectionRange = 15f; + public float attackRange = 2f; + public float fieldOfView = 120f; + + [Header("Performance")] + public float sensorUpdateRate = 5f; // Hz + + [Header("Behavior")] + public float reactionTime = 0.3f; + public float memoryDuration = 10f; + + private Transform player; + private Vector3 lastKnownPlayerPos; + private float timeSinceLastSeen = Mathf.Infinity; + private int currentWaypoint = 0; + + private enum State { Patrol, Investigate, Chase, Attack } + private State currentState = State.Patrol; + + private float nextSensorUpdate = 0f; + private bool cachedCanSeePlayer = false; + + void Start() + { + agent = GetComponent(); + animator = GetComponent(); + health = GetComponent(); + player = GameObject.FindGameObjectWithTag("Player").transform; + + health.OnDeath += OnDeath; + } + + void Update() + { + // Update sensors at fixed rate + if (Time.time >= nextSensorUpdate) + { + cachedCanSeePlayer = CanSeePlayer(); + nextSensorUpdate = Time.time + (1f / sensorUpdateRate); + } + + // Update memory + if (cachedCanSeePlayer) + { + lastKnownPlayerPos = player.position; + timeSinceLastSeen = 0f; + } + else + { + timeSinceLastSeen += Time.deltaTime; + } + + // State machine + switch (currentState) + { + case State.Patrol: + Patrol(); + if (cachedCanSeePlayer) StartCoroutine(DelayedChase()); + break; + + case State.Chase: + Chase(); + if (InAttackRange()) currentState = State.Attack; + if (!cachedCanSeePlayer && timeSinceLastSeen > 1f) currentState = State.Investigate; + break; + + case State.Investigate: + Investigate(); + if (cachedCanSeePlayer) currentState = State.Chase; + if (timeSinceLastSeen > memoryDuration) currentState = State.Patrol; + break; + + case State.Attack: + Attack(); + if (!InAttackRange()) currentState = State.Chase; + break; + } + + // Sync animation + animator.SetFloat("Speed", agent.velocity.magnitude); + animator.SetBool("IsAlerted", currentState == State.Chase || currentState == State.Attack); + } + + void Patrol() + { + if (patrolWaypoints.Length == 0) return; + + if (!agent.hasPath || agent.remainingDistance < 0.5f) + { + agent.SetDestination(patrolWaypoints[currentWaypoint].position); + currentWaypoint = (currentWaypoint + 1) % patrolWaypoints.Length; + } + } + + void Chase() + { + agent.SetDestination(lastKnownPlayerPos); + } + + void Investigate() + { + agent.SetDestination(lastKnownPlayerPos); + + if (agent.remainingDistance < 1f) + { + // Reached last known position, look around + transform.Rotate(Vector3.up * 30f * Time.deltaTime); + } + } + + void Attack() + { + agent.isStopped = true; + transform.LookAt(player); + animator.SetTrigger("Attack"); + } + + bool CanSeePlayer() + { + if (player == null) return false; + + float dist = Vector3.Distance(transform.position, player.position); + if (dist > detectionRange) return false; + + Vector3 dirToPlayer = (player.position - transform.position).normalized; + float angle = Vector3.Angle(transform.forward, dirToPlayer); + if (angle > fieldOfView / 2f) return false; + + RaycastHit hit; + if (Physics.Raycast(transform.position + Vector3.up, dirToPlayer, out hit, detectionRange)) + { + return hit.transform == player; + } + + return false; + } + + bool InAttackRange() + { + return Vector3.Distance(transform.position, player.position) < attackRange; + } + + IEnumerator DelayedChase() + { + // Reaction time + yield return new WaitForSeconds(Random.Range(reactionTime * 0.8f, reactionTime * 1.2f)); + currentState = State.Chase; + animator.SetTrigger("Alert"); + } + + void OnDeath() + { + enabled = false; + agent.enabled = false; + animator.SetTrigger("Death"); + } + + void OnDrawGizmos() + { + // FOV visualization + Gizmos.color = cachedCanSeePlayer ? Color.red : Color.yellow; + Gizmos.DrawWireSphere(transform.position, detectionRange); + + Vector3 forward = transform.forward * detectionRange; + Vector3 right = Quaternion.Euler(0, fieldOfView / 2f, 0) * forward; + Vector3 left = Quaternion.Euler(0, -fieldOfView / 2f, 0) * forward; + + Gizmos.DrawLine(transform.position, transform.position + right); + Gizmos.DrawLine(transform.position, transform.position + left); + + // State label + #if UNITY_EDITOR + UnityEditor.Handles.Label(transform.position + Vector3.up * 2, currentState.ToString()); + #endif + } +} +``` + +**Key Features**: +- ✅ NavMesh pathfinding +- ✅ Time-sliced sensors (5 Hz) +- ✅ Reaction delay (0.3s) +- ✅ Memory system (10s) +- ✅ Debug visualization (FOV, state) +- ✅ Animation integration +- ✅ Edge case handling (death, no waypoints) + + +### Example 5: Unreal Engine Behavior Tree (Visual Scripting) + +**Unreal's BTNode System**: + +Unreal provides visual BT editor. Conceptual structure: + +``` +Root +└─ Selector + ├─ Sequence (Combat) + │ ├─ BTDecorator_IsInRange (check: InCombatRange) + │ └─ BTTask_Attack + │ + ├─ Sequence (Chase) + │ ├─ BTDecorator_CanSeeTarget + │ └─ BTTask_MoveTo (MoveToTarget) + │ + └─ BTTask_Patrol +``` + +**C++ BTTask Example**: +```cpp +// Custom BT task in Unreal +UCLASS() +class UBTTask_FindCover : public UBTTaskNode +{ + GENERATED_BODY() + +public: + virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override + { + AAIController* AIController = OwnerComp.GetAIOwner(); + APawn* AIPawn = AIController->GetPawn(); + + // Find nearest cover point + TArray CoverPoints; + UGameplayStatics::GetAllActorsOfClass(GetWorld(), ACoverPoint::StaticClass(), CoverPoints); + + AActor* NearestCover = nullptr; + float MinDistance = FLT_MAX; + + for (AActor* Cover : CoverPoints) + { + float Distance = FVector::Dist(AIPawn->GetActorLocation(), Cover->GetActorLocation()); + if (Distance < MinDistance) + { + MinDistance = Distance; + NearestCover = Cover; + } + } + + if (NearestCover) + { + // Store in blackboard + OwnerComp.GetBlackboardComponent()->SetValueAsVector("CoverLocation", NearestCover->GetActorLocation()); + return EBTNodeResult::Succeeded; + } + + return EBTNodeResult::Failed; + } +}; +``` + +**Why Unreal BT**: +- Visual scripting (designers can modify without code) +- Built-in decorators (conditions) and services (periodic updates) +- Blackboard for data sharing +- Integrates with EQS (Environmental Query System) + + +## Cross-References + +### Use This Skill WITH: +- **pathfinding-algorithms**: A*, NavMesh, dynamic obstacles +- **performance-optimization-patterns**: Time-slicing, LOD, profiling AI +- **state-machines**: FSM basics before moving to BT/Utility/GOAP +- **animation-systems**: Syncing AI state with animations + +### Use This Skill AFTER: +- **game-architecture-fundamentals**: Understanding Update loops, managers +- **3d-math-essentials**: Vectors, angles, dot product for FOV calculations +- **design-patterns**: Understand Observer, State, Strategy patterns + +### Related Skills: +- **physics-simulation-patterns**: Pathfinding + physics integration +- **multiplayer-networking**: Syncing AI state across clients +- **procedural-animation**: IK, look-at for believability + + +## Testing Checklist + +### Architecture Validation +- [ ] Chose appropriate architecture (FSM ≤3 states, BT 4-10, Utility 5+, GOAP for planning) +- [ ] State/behavior diagram is maintainable (not spaghetti) +- [ ] Can add new behavior without major refactor +- [ ] Architecture matches complexity level + +### Performance +- [ ] Meets 60 FPS with max agent count (100+) +- [ ] Time-slicing implemented (not all AI updates every frame) +- [ ] LOD system for distant agents (optional) +- [ ] Profiled AI CPU time (< 5ms per frame) + +### Pathfinding +- [ ] Uses NavMesh or A* (not straight-line movement) +- [ ] Handles pathfinding failures (timeout, unreachable target) +- [ ] Avoids obstacles dynamically +- [ ] Paths are believable (not zig-zagging) + +### Sensing +- [ ] Vision uses FOV cone + raycast (not omniscient) +- [ ] Sensor updates time-sliced (not 60 Hz raycasts) +- [ ] Cached sensor data (AI doesn't raycast in decision logic) +- [ ] Hearing system works (sound propagation) + +### Believability +- [ ] Reaction delays (0.2-0.5s, not instant) +- [ ] Memory system (remembers last seen position) +- [ ] Controlled randomness (variance without chaos) +- [ ] Animation integration (AI state matches visuals) + +### Debug Tools +- [ ] State labels visible in scene view +- [ ] FOV/hearing range visualization (Gizmos) +- [ ] Path visualization (current destination) +- [ ] Decision logging (why AI chose action) + +### Edge Cases +- [ ] No waypoints (fallback behavior) +- [ ] Player unreachable (timeout after 5-10s) +- [ ] NavMesh missing (error message, not crash) +- [ ] Null references handled (player destroyed) +- [ ] AI death/disable handled gracefully + +### Extensibility +- [ ] Can add new behavior in < 30 minutes +- [ ] Can tune parameters without code changes +- [ ] Multiple AI types share code (inheritance/composition) +- [ ] Squad coordination possible to add later + + +## Summary + +AI and agent simulation for games requires balancing architecture choice, performance, and believability. The core principles are: + +1. **Choose architecture based on complexity** - FSM for simple, BT for hierarchical, Utility for context-dependent, GOAP for planning +2. **Time-slice AI updates** - Don't update all agents every frame +3. **Use engine pathfinding** - NavMesh in Unity, NavigationSystem in Unreal +4. **Separate sensing from decision-making** - Cache sensor data, update at lower frequency +5. **Add believability touches** - Reaction delays, memory, controlled randomness +6. **Debug visualization is mandatory** - Gizmos for FOV, state labels, path visualization +7. **Handle edge cases** - Pathfinding failures, timeouts, unreachable targets +8. **Test under pressure** - 100+ agents, edge cases, performance profiling + +Master these patterns and avoid the common pitfalls, and your AI will be performant, believable, and maintainable. diff --git a/skills/using-simulation-tactics/crowd-simulation.md b/skills/using-simulation-tactics/crowd-simulation.md new file mode 100644 index 0000000..4485c16 --- /dev/null +++ b/skills/using-simulation-tactics/crowd-simulation.md @@ -0,0 +1,2637 @@ + +# Crowd Simulation + +**When to use this skill**: When implementing crowds for parades, evacuations, stadium events, city streets, festivals, or any scenario requiring 100+ autonomous agents with realistic movement, collision avoidance, and group behaviors. Critical for open-world games, city builders, event simulators, and tactical games requiring believable crowd dynamics. + +**What this skill provides**: Master Boids algorithm (separation, alignment, cohesion), Reciprocal Velocity Obstacles (RVO), social forces model, formation patterns, spatial partitioning for O(1) neighbor queries, behavior and visual LOD systems, and GPU crowd rendering techniques. Learn when to use individual agents vs flow fields, how to scale from 100 to 10,000+ agents at 60 FPS, and achieve realistic vs stylized crowd behavior. + + +## Core Concepts + +### Boids Algorithm (Reynolds 1987) + +**What**: Three simple rules create emergent flocking behavior. Industry standard for crowd simulation. + +**The Three Rules**: + +1. **Separation**: Avoid crowding neighbors (steer away from nearby agents) +2. **Alignment**: Steer toward average heading of neighbors +3. **Cohesion**: Steer toward center of mass of neighbors + +```python +class Boid: + def __init__(self, position, velocity): + self.position = position # Vector2 + self.velocity = velocity # Vector2 + self.max_speed = 2.0 + self.max_force = 0.5 + + # Boids parameters + self.separation_radius = 1.5 # meters + self.alignment_radius = 3.0 + self.cohesion_radius = 3.0 + + self.separation_weight = 1.5 + self.alignment_weight = 1.0 + self.cohesion_weight = 1.0 + + def update(self, neighbors, dt): + """Update boid using three rules""" + # Calculate steering forces + separation_force = self.calculate_separation(neighbors) + alignment_force = self.calculate_alignment(neighbors) + cohesion_force = self.calculate_cohesion(neighbors) + + # Weight and combine forces + acceleration = ( + separation_force * self.separation_weight + + alignment_force * self.alignment_weight + + cohesion_force * self.cohesion_weight + ) + + # Update velocity and position + self.velocity += acceleration * dt + self.velocity = self.velocity.limit(self.max_speed) + self.position += self.velocity * dt + + def calculate_separation(self, neighbors): + """Rule 1: Steer away from nearby agents""" + steering = Vector2(0, 0) + count = 0 + + for other in neighbors: + distance = (self.position - other.position).length() + + if 0 < distance < self.separation_radius: + # Create avoidance force (stronger when closer) + diff = self.position - other.position + diff = diff.normalized() / distance # Weight by inverse distance + steering += diff + count += 1 + + if count > 0: + steering /= count + + # Steering = desired - current + steering = steering.normalized() * self.max_speed + steering -= self.velocity + steering = steering.limit(self.max_force) + + return steering + + def calculate_alignment(self, neighbors): + """Rule 2: Match velocity with neighbors""" + average_velocity = Vector2(0, 0) + count = 0 + + for other in neighbors: + distance = (self.position - other.position).length() + + if 0 < distance < self.alignment_radius: + average_velocity += other.velocity + count += 1 + + if count > 0: + average_velocity /= count + average_velocity = average_velocity.normalized() * self.max_speed + + steering = average_velocity - self.velocity + steering = steering.limit(self.max_force) + return steering + + return Vector2(0, 0) + + def calculate_cohesion(self, neighbors): + """Rule 3: Steer toward center of mass of neighbors""" + center_of_mass = Vector2(0, 0) + count = 0 + + for other in neighbors: + distance = (self.position - other.position).length() + + if 0 < distance < self.cohesion_radius: + center_of_mass += other.position + count += 1 + + if count > 0: + center_of_mass /= count + + # Steer toward center + desired = center_of_mass - self.position + desired = desired.normalized() * self.max_speed + + steering = desired - self.velocity + steering = steering.limit(self.max_force) + return steering + + return Vector2(0, 0) +``` + +**Why Boids Works**: +- **Local rules → Global behavior**: No central coordination needed +- **Emergent patterns**: Flocks, streams, avoidance emerge naturally +- **Computationally cheap**: Only uses neighbor positions/velocities +- **Scalable**: Each agent only considers neighbors, not entire crowd + +**Tuning Boids Parameters**: + +| Parameter | Low Value | High Value | Use Case | +|-----------|-----------|------------|----------| +| Separation weight | Agents touch | Agents stay apart | Panicked crowd (low), orderly queue (high) | +| Alignment weight | Chaotic directions | Uniform flow | Individuals (low), marching band (high) | +| Cohesion weight | Agents disperse | Tight clusters | Strangers (low), families (high) | +| Neighbor radius | Only closest agents | Large perception | Dense crowd (small), open field (large) | + +**Real-World Example**: *Batman: Arkham Knight* uses Boids for crowd panic scenes with 300+ civilians fleeing. + + +### Reciprocal Velocity Obstacles (RVO) + +**What**: Predictive collision avoidance. Each agent selects velocity that avoids future collisions, not just current ones. + +**Problem with Naive Collision**: Agents react AFTER collision starts, causing bouncing and overlap. + +**RVO Solution**: Calculate "velocity obstacles" - velocities that would cause collision in next N seconds. Avoid those velocities. + +```python +import math + +class RVOAgent: + def __init__(self, position, radius=0.4): + self.position = position + self.velocity = Vector2(0, 0) + self.radius = radius + self.max_speed = 1.5 + self.preferred_velocity = Vector2(0, 0) # Where agent wants to go + + # RVO parameters + self.time_horizon = 2.0 # Look ahead 2 seconds + self.neighbor_distance = 10.0 + + def compute_new_velocity(self, neighbors): + """Compute collision-free velocity using RVO""" + # Start with preferred velocity (toward goal) + new_velocity = self.preferred_velocity + + # For each neighbor, compute velocity obstacle + for other in neighbors: + if other == self: + continue + + distance = (other.position - self.position).length() + if distance > self.neighbor_distance: + continue + + # Calculate velocity obstacle (cone of velocities that cause collision) + vo_apex = self.position + relative_pos = other.position - self.position + relative_vel = self.velocity - other.velocity + + # Combined radius + combined_radius = self.radius + other.radius + + # If currently colliding, separate immediately + if distance < combined_radius: + # Emergency separation + separation = (self.position - other.position).normalized() + new_velocity += separation * self.max_speed + continue + + # Calculate velocity obstacle boundary + # (Simplified version - full RVO is more complex) + cutoff_center = relative_pos / self.time_horizon + + # Check if preferred velocity is inside velocity obstacle + if self._is_inside_vo(new_velocity, cutoff_center, combined_radius, distance): + # Find closest velocity outside VO + new_velocity = self._find_safe_velocity( + new_velocity, + cutoff_center, + combined_radius, + distance + ) + + return new_velocity.limit(self.max_speed) + + def _is_inside_vo(self, velocity, vo_center, radius, distance): + """Check if velocity leads to collision""" + relative_vel = velocity - vo_center + + # Check if velocity is in collision cone + distance_sq = relative_vel.length_squared() + collision_threshold = (radius / distance) ** 2 + + return distance_sq < collision_threshold + + def _find_safe_velocity(self, preferred, vo_center, radius, distance): + """Find velocity outside velocity obstacle closest to preferred""" + # Simplified: Move perpendicular to collision direction + to_obstacle = vo_center.normalized() + perpendicular = Vector2(-to_obstacle.y, to_obstacle.x) + + # Try both perpendicular directions + option1 = to_obstacle * (self.max_speed * 0.5) + perpendicular * self.max_speed + option2 = to_obstacle * (self.max_speed * 0.5) - perpendicular * self.max_speed + + # Pick closest to preferred velocity + if (option1 - preferred).length() < (option2 - preferred).length(): + return option1 + else: + return option2 +``` + +**RVO vs Boids Separation**: + +| Aspect | Boids Separation | RVO | +|--------|------------------|-----| +| Timing | Reactive (avoid current collision) | Predictive (avoid future collision) | +| Computation | Simple distance checks | Velocity obstacle calculation | +| Quality | Agents can overlap briefly | Smooth collision-free movement | +| Cost | Very cheap | Moderate cost | +| Use Case | Large crowds (1000+) | Hero agents, visible crowds | + +**When to Use Each**: +- **Boids**: Dense crowds where slight overlap is acceptable (protests, stadiums) +- **RVO**: Important agents where collision-free is critical (hero NPCs, cutscenes) +- **Hybrid**: RVO for nearby agents (< 20m), Boids for distant (> 20m) - LOD approach + +**Real-World Example**: *Unity's NavMesh Obstacle Avoidance* uses RVO variant for collision-free agent movement. + + +### Social Forces Model (Helbing) + +**What**: Psychological forces in addition to physical collision. People avoid each other BEFORE touching. + +**Concept**: Agents have "personal space" they defend. Getting too close creates repulsive force. + +```python +class SocialForceAgent: + def __init__(self, position): + self.position = position + self.velocity = Vector2(0, 0) + self.goal = None + + # Social forces parameters + self.desired_speed = 1.34 # Average human walking speed (m/s) + self.personal_space = 0.8 # meters (comfort zone) + self.mass = 80 # kg + + # Force weights + self.goal_force_weight = 2.0 + self.social_force_weight = 2.1 + self.obstacle_force_weight = 10.0 + + # Relaxation time (how quickly agent adjusts velocity toward desired) + self.tau = 0.5 # seconds + + def compute_forces(self, neighbors, obstacles): + """Calculate all forces acting on agent""" + # Force 1: Goal attraction (where agent wants to go) + goal_force = self.calculate_goal_force() + + # Force 2: Social repulsion (avoid other agents) + social_force = self.calculate_social_force(neighbors) + + # Force 3: Obstacle repulsion (avoid walls) + obstacle_force = self.calculate_obstacle_force(obstacles) + + # Total force + total_force = ( + goal_force * self.goal_force_weight + + social_force * self.social_force_weight + + obstacle_force * self.obstacle_force_weight + ) + + return total_force + + def calculate_goal_force(self): + """Force pulling agent toward goal""" + if self.goal is None: + return Vector2(0, 0) + + # Desired velocity (direction to goal at desired speed) + direction = (self.goal - self.position).normalized() + desired_velocity = direction * self.desired_speed + + # Force to adjust current velocity toward desired + # F = m * (v_desired - v_current) / tau + force = self.mass * (desired_velocity - self.velocity) / self.tau + + return force + + def calculate_social_force(self, neighbors): + """Repulsive force from nearby agents""" + total_force = Vector2(0, 0) + + for other in neighbors: + # Vector from other to self + relative_pos = self.position - other.position + distance = relative_pos.length() + + if distance < 0.01: + distance = 0.01 # Avoid division by zero + + # Exponential decay with distance + # Closer agents = stronger repulsion + A = 2.1 # Interaction strength + B = 0.3 # Interaction range + + magnitude = A * math.exp((self.personal_space - distance) / B) + direction = relative_pos.normalized() + + force = direction * magnitude + total_force += force + + return total_force + + def calculate_obstacle_force(self, obstacles): + """Repulsive force from walls/obstacles""" + total_force = Vector2(0, 0) + + for obstacle in obstacles: + # Find closest point on obstacle to agent + closest_point = obstacle.closest_point(self.position) + + relative_pos = self.position - closest_point + distance = relative_pos.length() + + if distance < 0.01: + distance = 0.01 + + # Stronger repulsion than social force + A = 10.0 + B = 0.2 + + magnitude = A * math.exp(-distance / B) + direction = relative_pos.normalized() + + force = direction * magnitude + total_force += force + + return total_force + + def update(self, neighbors, obstacles, dt): + """Update agent using social forces""" + forces = self.compute_forces(neighbors, obstacles) + + # F = ma, so a = F/m + acceleration = forces / self.mass + + # Update velocity and position + self.velocity += acceleration * dt + + # Limit speed + speed = self.velocity.length() + if speed > self.desired_speed * 1.3: # Allow 30% over desired + self.velocity = self.velocity.normalized() * (self.desired_speed * 1.3) + + self.position += self.velocity * dt +``` + +**Social Forces vs Boids**: + +| Aspect | Boids | Social Forces | +|--------|-------|---------------| +| Origin | Biological (birds, fish) | Psychological (humans) | +| Personal space | No explicit concept | Explicit personal space radius | +| Goal-seeking | Added separately | Built into model | +| Calibration | Easier to tune | More parameters, harder to tune | +| Realism | Good for animals | Better for humans | + +**When to Use Social Forces**: +- Human crowds (not animals/aliens) +- Evacuation simulations (real-world validation needed) +- High-density crowds (subway, concerts) +- When psychological realism matters + +**Real-World Example**: *Crowd simulation for stadium evacuations* uses Social Forces for safety analysis. + + +### Spatial Partitioning for O(1) Neighbor Queries + +**Problem**: Finding neighbors is O(n). With 1000 agents, each neighbor query searches 1000 agents. + +**Solution**: Spatial hash grid. Divide space into cells, each agent only checks its cell + adjacent cells. + +```python +class SpatialHashGrid: + def __init__(self, cell_size=2.0): + self.cell_size = cell_size # meters + self.grid = {} # {(cell_x, cell_y): [agents in cell]} + + def clear(self): + """Clear all cells (call once per frame before rebuild)""" + self.grid.clear() + + def get_cell(self, position): + """Get cell coordinates for position""" + cell_x = int(position.x / self.cell_size) + cell_y = int(position.y / self.cell_size) + return (cell_x, cell_y) + + def insert(self, agent): + """Insert agent into grid""" + cell = self.get_cell(agent.position) + + if cell not in self.grid: + self.grid[cell] = [] + + self.grid[cell].append(agent) + + def query_neighbors(self, position, radius): + """Get all agents within radius of position""" + # Determine which cells to check + min_x = int((position.x - radius) / self.cell_size) + max_x = int((position.x + radius) / self.cell_size) + min_y = int((position.y - radius) / self.cell_size) + max_y = int((position.y + radius) / self.cell_size) + + neighbors = [] + + # Check all cells in range + for cx in range(min_x, max_x + 1): + for cy in range(min_y, max_y + 1): + cell = (cx, cy) + + if cell in self.grid: + # Check each agent in cell + for agent in self.grid[cell]: + distance = (agent.position - position).length() + if distance <= radius: + neighbors.append(agent) + + return neighbors + + +# Usage in crowd simulation +class CrowdSimulation: + def __init__(self): + self.agents = [] + self.spatial_grid = SpatialHashGrid(cell_size=3.0) + + def update(self, dt): + # STEP 1: Rebuild spatial grid + self.spatial_grid.clear() + for agent in self.agents: + self.spatial_grid.insert(agent) + + # STEP 2: Update each agent using spatial queries + for agent in self.agents: + # Get neighbors (FAST - only checks nearby cells) + neighbors = self.spatial_grid.query_neighbors( + agent.position, + radius=5.0 + ) + + # Update agent with neighbors + agent.update(neighbors, dt) +``` + +**Performance Comparison**: + +``` +Naive neighbor search (no spatial structure): +- 1000 agents, each searches 1000 = 1,000,000 checks per frame +- At 60 FPS = 60,000,000 checks per second +- Cost: O(n²) + +Spatial hash grid: +- 1000 agents, each searches ~9 cells × ~10 agents = ~90 checks per frame +- At 60 FPS = 5,400,000 checks per second +- Cost: O(n) - linear, not quadratic +- Speedup: 11× faster (in practice, 10-50× depending on density) +``` + +**Cell Size Tuning**: +- **Too small**: Many cells, more cells to check per query +- **Too large**: Many agents per cell, back to O(n) within cell +- **Rule of thumb**: Cell size = 2 × neighbor query radius +- **Example**: If agents check 3m radius, use 6m cell size + +**Alternative Spatial Structures**: + +| Structure | Query Cost | Insert Cost | Best For | +|-----------|------------|-------------|----------| +| Spatial hash grid | O(1) avg | O(1) | Uniform density, dynamic | +| Quadtree | O(log n) | O(log n) | Non-uniform density | +| BVH | O(log n) | O(n log n) | Static obstacles | +| kd-tree | O(log n) | O(n log n) | Static agents | + +**Use spatial hash for crowd simulation** - best balance of speed and simplicity for dynamic agents. + +**Real-World Example**: *Unity's ECS* uses spatial hash for entity queries in Data-Oriented Technology Stack (DOTS). + + +### Level-of-Detail (LOD) Systems + +**Problem**: All 1000 agents run full simulation even when 900 are off-screen or distant. + +**Solution**: LOD hierarchy - distant agents use simpler simulation. + +#### Visual LOD (Rendering) + +```python +class CrowdRenderer: + def __init__(self): + # Three mesh levels + self.high_detail_mesh = load_mesh("agent_high.obj") # 5000 tris + self.medium_detail_mesh = load_mesh("agent_med.obj") # 1000 tris + self.low_detail_mesh = load_mesh("agent_low.obj") # 200 tris + self.impostor_sprite = load_texture("agent_sprite.png") # Billboard + + # Distance thresholds + self.high_lod_distance = 20.0 # meters + self.medium_lod_distance = 50.0 + self.low_lod_distance = 100.0 + + def render(self, agent, camera_position): + """Render agent with LOD based on distance""" + distance = (agent.position - camera_position).length() + + if distance < self.high_lod_distance: + # Close: Full detail mesh + skeleton + cloth sim + render_mesh(agent, self.high_detail_mesh) + update_skeleton(agent) + simulate_cloth(agent) + + elif distance < self.medium_lod_distance: + # Medium: Simplified mesh + skeleton, no cloth + render_mesh(agent, self.medium_detail_mesh) + update_skeleton(agent) + + elif distance < self.low_lod_distance: + # Far: Low-poly mesh, no skeleton (baked animations) + render_mesh(agent, self.low_detail_mesh) + + else: + # Very far: Billboard sprite + render_impostor(agent, self.impostor_sprite, camera_position) +``` + +#### Behavior LOD (Simulation) + +```python +class LODCrowdSimulation: + def __init__(self): + self.agents = [] + + # LOD distance thresholds + self.lod0_distance = 30.0 # Full simulation + self.lod1_distance = 100.0 # Simplified simulation + self.lod2_distance = 200.0 # Very simplified + # Beyond lod2_distance: No simulation (frozen or scripted) + + def update(self, dt, camera_position): + for agent in self.agents: + distance = (agent.position - camera_position).length() + + if distance < self.lod0_distance: + # LOD 0: Full simulation + # - Full Boids (separation, alignment, cohesion) + # - RVO collision avoidance + # - Individual pathfinding + # - Social forces + agent.update_full(dt) + + elif distance < self.lod1_distance: + # LOD 1: Simplified simulation (30 Hz instead of 60 Hz) + if frame_count % 2 == 0: # Every other frame + # - Basic Boids (separation only) + # - No RVO (use simple repulsion) + # - Flow field instead of pathfinding + agent.update_simple(dt * 2) # Double dt to compensate + + elif distance < self.lod2_distance: + # LOD 2: Very simplified (10 Hz) + if frame_count % 6 == 0: # Every 6th frame + # - No collision avoidance + # - Follow flow field only + # - No neighbor queries + agent.update_minimal(dt * 6) + + else: + # LOD 3: Frozen or scripted movement + # Agent moves on rails or doesn't move at all + pass # No update +``` + +**LOD Performance Impact**: + +``` +Without LOD (1000 agents): +- All agents: Full simulation at 60 Hz +- CPU time: ~50ms per frame (unplayable) + +With LOD (1000 agents, camera in crowd): +- 50 agents in LOD 0 (< 30m): Full simulation at 60 Hz → 2.5ms +- 200 agents in LOD 1 (30-100m): Simple simulation at 30 Hz → 3.3ms +- 400 agents in LOD 2 (100-200m): Minimal simulation at 10 Hz → 2.2ms +- 350 agents in LOD 3 (> 200m): No simulation → 0ms +- Total CPU time: ~8ms per frame (playable at 60 FPS!) + +Speedup: 6× faster +``` + +**LOD Best Practices**: +1. **Hysteresis**: Add buffer to distance thresholds to prevent LOD thrashing + ```python + # Transition from LOD 0 → LOD 1 at 30m + # Transition from LOD 1 → LOD 0 at 25m (5m buffer) + ``` +2. **Budget-based LOD**: Limit number of high-LOD agents regardless of distance +3. **Visibility-based**: Off-screen agents always low LOD even if close +4. **Smooth transitions**: Blend between LOD levels over 1-2 seconds + +**Real-World Example**: *Assassin's Creed Unity* renders 10,000+ NPCs using 5-level LOD system (high detail, medium, low, impostor, culled). + + +### Formation Patterns + +**Problem**: Groups of agents (families, squads) should stay together in cohesive shape. + +**Solution**: Formation system with leader-follower and slot assignment. + +```python +class Formation: + def __init__(self, formation_type="line"): + self.leader = None + self.followers = [] + self.formation_type = formation_type + + # Formation parameters + self.slot_spacing = 1.5 # meters between slots + self.cohesion_strength = 2.0 + self.max_slot_distance = 10.0 # Break formation if too far + + def calculate_slot_positions(self): + """Calculate target positions for each follower""" + if self.leader is None: + return [] + + slots = [] + leader_forward = self.leader.velocity.normalized() + leader_right = Vector2(-leader_forward.y, leader_forward.x) + + if self.formation_type == "line": + # Line formation: X X X X X + for i, follower in enumerate(self.followers): + offset = (i + 1) * self.slot_spacing + slot_pos = ( + self.leader.position - + leader_forward * offset + ) + slots.append(slot_pos) + + elif self.formation_type == "wedge": + # Wedge formation: X + # X X + # X X + row = 0 + col = 0 + for i, follower in enumerate(self.followers): + row = int((i + 1) / 2) + 1 + col = -row if i % 2 == 0 else row + + slot_pos = ( + self.leader.position - + leader_forward * (row * self.slot_spacing) + + leader_right * (col * self.slot_spacing * 0.5) + ) + slots.append(slot_pos) + + elif self.formation_type == "column": + # Column formation: X + # X + # X + for i, follower in enumerate(self.followers): + offset = (i + 1) * self.slot_spacing + slot_pos = ( + self.leader.position - + leader_forward * offset + ) + slots.append(slot_pos) + + elif self.formation_type == "circle": + # Circle around leader + num_followers = len(self.followers) + radius = self.slot_spacing * 2 + + for i, follower in enumerate(self.followers): + angle = (i / num_followers) * 2 * math.pi + slot_pos = self.leader.position + Vector2( + math.cos(angle) * radius, + math.sin(angle) * radius + ) + slots.append(slot_pos) + + return slots + + def update_formation(self, dt): + """Update follower positions to maintain formation""" + slot_positions = self.calculate_slot_positions() + + for i, follower in enumerate(self.followers): + target_slot = slot_positions[i] + + # Distance to assigned slot + to_slot = target_slot - follower.position + distance_to_slot = to_slot.length() + + # If too far from slot, move toward it strongly + if distance_to_slot > self.max_slot_distance: + # Break formation, catch up to leader + follower.goal = self.leader.position + else: + # Maintain formation slot + # Blend between following slot and avoiding collisions + slot_weight = min(distance_to_slot / self.slot_spacing, 1.0) + + # Seek slot position + desired_velocity = to_slot.normalized() * follower.max_speed + + # Also maintain some separation from other followers + neighbors = get_nearby_agents(follower.position, radius=3.0) + separation_force = calculate_separation(follower, neighbors) + + # Combine: mostly follow slot, some separation + follower.steering_force = ( + desired_velocity * slot_weight * 0.7 + + separation_force * 0.3 + ) + + +class FormationManager: + def __init__(self): + self.formations = [] + + def create_family_group(self, agents): + """Create formation for family walking together""" + if len(agents) < 2: + return + + # Pick fastest agent as leader (parent) + leader = max(agents, key=lambda a: a.max_speed) + followers = [a for a in agents if a != leader] + + formation = Formation(formation_type="line") + formation.leader = leader + formation.followers = followers + formation.slot_spacing = 1.2 # Tight spacing for families + + self.formations.append(formation) + return formation + + def create_march_formation(self, agents): + """Create military-style march formation""" + if len(agents) < 4: + return + + leader = agents[0] + followers = agents[1:] + + formation = Formation(formation_type="column") + formation.leader = leader + formation.followers = followers + formation.slot_spacing = 1.0 # Tight column + + self.formations.append(formation) + return formation + + def update_all_formations(self, dt): + """Update all formations""" + for formation in self.formations: + formation.update_formation(dt) +``` + +**Formation Types and Use Cases**: + +| Formation | Shape | Use Case | +|-----------|-------|----------| +| Line | `X X X X` | Families walking side-by-side | +| Column | `X X X X` (vertical) | Marching, narrow paths | +| Wedge | `X X X X X` (V-shape) | Military squads, aggressive movement | +| Circle | `X X X` (around leader) | Defensive, crowd around celebrity | +| Scatter | Random offsets | Casual groups, tourists | + +**Formation Breaking**: +- Followers too far from slot → Break formation, catch up +- Leader stops → Followers gather around +- Obstacle blocks slot → Follower temporarily leaves formation +- Leader changes direction rapidly → Formation reforms with delay + +**Real-World Example**: *Total War* games use formations for thousands of soldiers in regiments with tight cohesion. + + +## Decision Frameworks + +### Framework 1: Individual Agents vs Flow Fields + +**Question**: Should I simulate each agent individually or use flow fields? + +``` +START: How many agents moving to SAME destination? + +├─ < 50 agents +│ └─ Use INDIVIDUAL PATHFINDING (A*, NavMesh) +│ - Each agent has unique path +│ - Full steering behaviors +│ - Collision avoidance per agent +│ +├─ 50-200 agents to SAME goal +│ └─ Use HYBRID +│ - Flow field for general direction +│ - Individual steering for local avoidance +│ - Best of both worlds +│ +└─ 200+ agents to SAME goal + └─ Use FLOW FIELDS + - Pre-compute direction field to goal + - All agents follow field (O(1) per agent) + - Add randomness to prevent uniformity +``` + +**Flow Field Implementation**: + +```python +class FlowField: + def __init__(self, grid_size, cell_size): + self.grid_size = grid_size # e.g., 100x100 + self.cell_size = cell_size # e.g., 2 meters + self.direction_field = np.zeros((grid_size, grid_size, 2)) # Direction vectors + + def generate_from_goal(self, goal_position, obstacles): + """Generate flow field pointing toward goal using Dijkstra""" + # Step 1: Initialize cost field (distance to goal) + cost_field = np.full((self.grid_size, self.grid_size), np.inf) + + # Goal cell has cost 0 + goal_cell = self.world_to_cell(goal_position) + cost_field[goal_cell] = 0 + + # Step 2: Propagate costs (Dijkstra from goal) + open_set = [goal_cell] + + while open_set: + current = open_set.pop(0) + current_cost = cost_field[current] + + # Check neighbors + for neighbor in self.get_neighbors(current): + if self.is_obstacle(neighbor, obstacles): + continue + + new_cost = current_cost + 1 # Uniform cost + + if new_cost < cost_field[neighbor]: + cost_field[neighbor] = new_cost + open_set.append(neighbor) + + # Step 3: Generate direction field (downhill in cost field) + for x in range(self.grid_size): + for y in range(self.grid_size): + if cost_field[x, y] == np.inf: + continue # Unreachable + + # Find neighbor with lowest cost + best_neighbor = None + best_cost = cost_field[x, y] + + for nx, ny in self.get_neighbors((x, y)): + if cost_field[nx, ny] < best_cost: + best_cost = cost_field[nx, ny] + best_neighbor = (nx, ny) + + if best_neighbor: + # Direction points to best neighbor + direction = Vector2( + best_neighbor[0] - x, + best_neighbor[1] - y + ).normalized() + self.direction_field[x, y] = [direction.x, direction.y] + + def get_direction(self, world_position): + """Get flow direction at world position""" + cell = self.world_to_cell(world_position) + + if self.is_valid_cell(cell): + dir_x, dir_y = self.direction_field[cell] + return Vector2(dir_x, dir_y) + + return Vector2(0, 0) + + +# Agent using flow field +class FlowFieldAgent: + def update(self, flow_field, dt): + # Get direction from flow field + flow_direction = flow_field.get_direction(self.position) + + # Follow flow with some randomness + random_offset = Vector2( + random.uniform(-0.2, 0.2), + random.uniform(-0.2, 0.2) + ) + + desired_velocity = (flow_direction + random_offset).normalized() * self.speed + + # Still need local collision avoidance + neighbors = get_nearby(self.position, radius=2.0) + separation = calculate_separation(self, neighbors) + + # Combine flow following (80%) and separation (20%) + self.velocity = desired_velocity * 0.8 + separation * 0.2 + self.position += self.velocity * dt +``` + +**Performance Comparison**: + +``` +500 agents going to same exit: + +Individual A*: +- 500 agents × 1ms pathfinding = 500ms per frame +- Result: 2 FPS (unplayable) + +Flow Field: +- Generate field once: 10ms (one-time cost) +- 500 agents × 0.01ms follow field = 5ms per frame +- Result: 60 FPS (playable!) + +Speedup: 100× faster +``` + +**When Flow Fields Fail**: +- Agents have DIFFERENT destinations → Need individual paths +- Complex multi-level geometry → Flow field doesn't handle 3D well +- Small number of agents (< 50) → Overhead of generating field not worth it + + +### Framework 2: When Does LOD Become Mandatory? + +**Question**: At what crowd size do I MUST implement LOD? + +``` +Agent count thresholds: + +├─ < 50 agents +│ └─ LOD OPTIONAL +│ - Can run full simulation at 60 FPS +│ - LOD adds complexity, may not be worth it +│ - Exception: If agents have expensive AI (GOAP, complex pathfinding) +│ +├─ 50-200 agents +│ └─ VISUAL LOD RECOMMENDED +│ - Rendering becomes bottleneck +│ - Use mesh LOD (high/medium/low poly) +│ - Behavior LOD still optional +│ +├─ 200-1000 agents +│ └─ BEHAVIOR + VISUAL LOD REQUIRED +│ - Simulation becomes bottleneck +│ - Use frequency reduction (60 Hz → 30 Hz → 10 Hz) +│ - Use simplified algorithms (Boids only, no RVO) +│ +└─ 1000+ agents + └─ AGGRESSIVE LOD + GPU REQUIRED + - Multi-level behavior LOD (4+ levels) + - GPU rendering (instancing, compute shaders) + - Consider GPU simulation (compute shaders) +``` + +**LOD Budget Example**: + +``` +Target: 1000 agents at 60 FPS +Frame budget: 16.67ms +Simulation budget: 8ms (50% of frame) + +Without LOD: +- 1000 agents × 0.05ms = 50ms per frame +- Result: FAIL (need 8ms, have 50ms) + +With 3-level LOD: +- LOD 0 (50 agents, < 30m): Full sim at 60 Hz = 2.5ms +- LOD 1 (200 agents, 30-100m): Simple sim at 30 Hz = 3.3ms +- LOD 2 (400 agents, 100-200m): Minimal sim at 10 Hz = 2.2ms +- LOD 3 (350 agents, > 200m): No sim = 0ms +- Total: 8ms per frame +- Result: SUCCESS (within budget) +``` + +**Rule of Thumb**: +- **50 agents**: No LOD needed +- **100 agents**: Visual LOD recommended +- **200 agents**: Behavior LOD required +- **500+ agents**: Multi-level LOD required +- **1000+ agents**: GPU simulation consideration + + +### Framework 3: Realistic vs Stylized Crowd Behavior + +**Question**: How realistic should crowd behavior be? + +``` +Determine realism level based on: + +├─ SIMULATION PURPOSE +│ ├─ Safety analysis (stadium evacuation) → MAXIMUM realism +│ ├─ Game background (city streets) → MODERATE realism +│ └─ Arcade game (zombie horde) → STYLIZED, not realistic +│ +├─ PLAYER INTERACTION +│ ├─ Player watches closely → HIGH realism needed +│ ├─ Player occasionally looks → MODERATE realism +│ └─ Background only → LOW realism sufficient +│ +└─ PERFORMANCE BUDGET + ├─ High budget (< 500 agents) → Can afford realism + └─ Low budget (1000+ agents) → Must simplify +``` + +**Realism Spectrum**: + +| Level | Characteristics | Techniques | Use Case | +|-------|-----------------|------------|----------| +| **Maximum Realism** | Validated against real crowds | Social forces, RVO, personality variation, dynamic speeds | Safety simulations | +| **High Realism** | Looks believable to player | Boids + RVO, formations, some personality | AAA game crowds | +| **Moderate Realism** | Looks okay if not scrutinized | Boids only, no RVO, uniform agents | City builder background | +| **Low Realism (Stylized)** | Acceptable for genre | Flow fields, no collision (some overlap okay) | RTS unit swarms | +| **Fake** | Illusion of crowd | Scripted movement, looping animations | Very distant background | + +**Tuning for Realism**: + +```python +# Realistic human crowd (parade, evacuation) +class RealisticCrowdConfig: + # Speed variation (not all same speed) + speed_mean = 1.34 # m/s (average human walking) + speed_stddev = 0.2 # ±0.2 m/s variation + + # Personal space (people avoid before touching) + personal_space = 0.8 # meters + comfortable_density = 2.0 # people per square meter + max_density = 5.5 # crushes above this (dangerous!) + + # Reaction time (not instant) + reaction_time_mean = 0.4 # seconds + reaction_time_stddev = 0.1 + + # Anticipation (look ahead) + anticipation_distance = 3.0 # meters + + # Group behavior (70% of people in groups) + group_probability = 0.7 + group_size_mean = 3 # Average family size + + # Elderly/children slower + child_speed_multiplier = 0.7 + elderly_speed_multiplier = 0.6 + + +# Stylized game crowd (zombies, swarm) +class StylizedCrowdConfig: + # Uniform speed (all identical) + speed = 2.0 # m/s (faster than humans) + + # No personal space (can overlap slightly) + personal_space = 0.0 + comfortable_density = 10.0 # Densely packed + + # Instant reaction (video game feel) + reaction_time = 0.0 + + # No anticipation (reactive only) + anticipation_distance = 0.0 + + # No groups (all individuals) + group_probability = 0.0 +``` + +**Believability vs Performance**: +- **80/20 rule**: Get 80% believability with 20% of effort +- **Good enough threshold**: If playtesters don't notice issues, stop +- **Don't over-optimize**: Distant crowds can be very simple without hurting experience + + +### Framework 4: Collision Strategy Selection + +**Question**: Which collision avoidance should I use? + +``` +Choose based on agent importance and density: + +├─ HERO AGENTS (player companions, boss enemies) +│ └─ Use RVO (Reciprocal Velocity Obstacles) +│ - Collision-free movement +│ - Smooth paths +│ - Higher CPU cost justified +│ +├─ VISIBLE AGENTS (near camera, < 50 agents) +│ └─ Use BOIDS + SOCIAL FORCES +│ - Psychological avoidance +│ - Good enough quality +│ - Moderate CPU cost +│ +├─ DENSE CROWDS (> 100 agents/m², protests, stadiums) +│ └─ Use BOIDS SEPARATION ONLY +│ - Simple distance-based repulsion +│ - Very cheap +│ - Slight overlap acceptable +│ +└─ DISTANT AGENTS (> 100m from camera) + └─ Use NOTHING (flow field only) + - No collision avoidance + - Agents can overlap + - Zero CPU cost +``` + +**Collision Cost Comparison**: + +| Method | CPU per Agent | Quality | Overlaps? | +|--------|---------------|---------|-----------| +| RVO | 0.5ms | Excellent (collision-free) | Never | +| Boids + Social Forces | 0.1ms | Good (rare overlap) | Rarely | +| Boids Separation Only | 0.05ms | Acceptable | Occasionally | +| Simple Repulsion | 0.01ms | Poor (frequent overlap) | Often | +| None (flow field only) | 0.001ms | N/A (used for distant agents) | Always | + +**Hybrid Approach** (Recommended): + +```python +def choose_collision_method(agent, camera_position): + """Select collision method based on agent importance""" + distance = (agent.position - camera_position).length() + + if agent.is_hero: + return "RVO" # Best quality + elif distance < 30 and is_visible(agent): + return "boids_social" # Good quality + elif distance < 100: + return "boids_separation" # Acceptable quality + else: + return "none" # No collision, use flow field +``` + + +### Framework 5: When to Use GPU Simulation + +**Question**: Should I move simulation to GPU (compute shaders)? + +``` +GPU simulation becomes worthwhile when: + +├─ AGENT COUNT > 5000 +│ └─ CPU can't handle even with LOD +│ - Consider GPU compute shaders +│ - 100× speedup possible +│ - Requires GPU programming knowledge +│ +├─ UNIFORM AGENTS (all identical behavior) +│ └─ GPU excels at SIMD (same instruction, many agents) +│ - Example: Zombie horde (all chase player) +│ - Counter-example: NPCs with varied behaviors (bad for GPU) +│ +├─ SIMPLE BEHAVIORS (Boids, flow fields) +│ └─ GPU good for simple math +│ - Boids: Good for GPU +│ - RVO: Harder (conditionals, per-agent logic) +│ - Pathfinding: Very hard on GPU +│ +└─ TARGET PLATFORM has strong GPU + └─ PC, Console: GPU usually available + Mobile: GPU may be busy with rendering +``` + +**GPU Simulation Pros/Cons**: + +**Pros**: +- ✅ 100-1000× faster for simple behaviors +- ✅ Can simulate 50,000+ agents +- ✅ Frees CPU for other tasks + +**Cons**: +- ❌ Requires GPU programming (CUDA, compute shaders) +- ❌ Hard to debug (no printf, breakpoints limited) +- ❌ Conditionals are slow (if/else on GPU is expensive) +- ❌ CPU-GPU memory transfer is slow (minimize transfers) + +**When NOT to Use GPU**: +- Complex decision-making (BTs, GOAP) → Stay on CPU +- Varied agent types (civilians, guards, vendors) → Stay on CPU +- Small agent counts (< 1000) → Not worth complexity + +**Real-World Example**: *Unity ECS + Burst Compiler* gives 10-100× speedup without GPU programming. + + +## Implementation Patterns + +### Pattern 1: Complete Crowd System with Boids + Spatial Hash + LOD + +Production-ready crowd simulation addressing all RED failures: + +```python +import math +import random +from dataclasses import dataclass +from typing import List +import numpy as np + +# ============================================================================ +# SPATIAL HASH GRID (Fixes O(n²) neighbor queries) +# ============================================================================ + +class SpatialHashGrid: + def __init__(self, cell_size=3.0): + self.cell_size = cell_size + self.grid = {} + + def clear(self): + self.grid.clear() + + def get_cell_key(self, x, y): + cx = int(x / self.cell_size) + cy = int(y / self.cell_size) + return (cx, cy) + + def insert(self, agent): + key = self.get_cell_key(agent.position[0], agent.position[1]) + if key not in self.grid: + self.grid[key] = [] + self.grid[key].append(agent) + + def query_radius(self, position, radius): + """Get all agents within radius of position""" + x, y = position + + # Determine cell range to check + r_cells = int(radius / self.cell_size) + 1 + cx_center = int(x / self.cell_size) + cy_center = int(y / self.cell_size) + + neighbors = [] + + for dx in range(-r_cells, r_cells + 1): + for dy in range(-r_cells, r_cells + 1): + key = (cx_center + dx, cy_center + dy) + if key in self.grid: + for agent in self.grid[key]: + dist_sq = (agent.position[0] - x)**2 + (agent.position[1] - y)**2 + if dist_sq <= radius * radius: + neighbors.append(agent) + + return neighbors + + +# ============================================================================ +# AGENT (Fixes overlapping, robotic movement, no personality) +# ============================================================================ + +@dataclass +class AgentConfig: + """Per-agent personality parameters (fixes "all agents identical")""" + speed_multiplier: float = 1.0 # 0.8-1.2 for variation + personal_space: float = 0.8 # meters + reaction_delay: float = 0.0 # seconds + risk_tolerance: float = 1.0 # 0.5-1.5 (affects separation weight) + + +class CrowdAgent: + def __init__(self, position, goal): + self.position = np.array(position, dtype=float) + self.velocity = np.array([0.0, 0.0], dtype=float) + self.goal = np.array(goal, dtype=float) + + # Personality (adds variation) + self.config = AgentConfig( + speed_multiplier=random.uniform(0.85, 1.15), + personal_space=random.uniform(0.6, 1.0), + risk_tolerance=random.uniform(0.8, 1.2) + ) + + # Physics + self.max_speed = 1.4 * self.config.speed_multiplier # m/s + self.max_force = 3.0 + self.radius = 0.35 # meters (human shoulder width) + + # Boids parameters + self.separation_radius = 2.0 + self.alignment_radius = 4.0 + self.cohesion_radius = 4.0 + + self.separation_weight = 2.0 * self.config.risk_tolerance + self.alignment_weight = 1.0 + self.cohesion_weight = 1.0 + self.goal_weight = 1.5 + + # LOD state + self.lod_level = 0 # 0=full, 1=simple, 2=minimal, 3=frozen + self.update_frequency = 1 # Update every N frames + + def update(self, neighbors, dt, frame_count): + """Update agent with LOD-aware simulation""" + # LOD: Skip update based on frequency + if frame_count % self.update_frequency != 0: + # Still move based on current velocity + self.position += self.velocity * dt * self.update_frequency + return + + # Adjust dt for skipped frames + effective_dt = dt * self.update_frequency + + # Calculate steering forces based on LOD level + if self.lod_level == 0: + # Full simulation + forces = self.calculate_full_forces(neighbors) + elif self.lod_level == 1: + # Simplified (separation + goal only) + forces = self.calculate_simple_forces(neighbors) + elif self.lod_level == 2: + # Minimal (goal only, no collision) + forces = self.calculate_minimal_forces() + else: # lod_level == 3 + # Frozen (no update) + return + + # Update velocity + acceleration = forces / 80.0 # Assume 80kg mass + self.velocity += acceleration * effective_dt + + # Limit speed + speed = np.linalg.norm(self.velocity) + if speed > self.max_speed: + self.velocity = (self.velocity / speed) * self.max_speed + + # Update position + self.position += self.velocity * effective_dt + + def calculate_full_forces(self, neighbors): + """Full Boids + goal seeking""" + separation = self.calculate_separation(neighbors) + alignment = self.calculate_alignment(neighbors) + cohesion = self.calculate_cohesion(neighbors) + goal_seek = self.calculate_goal_seek() + + total = ( + separation * self.separation_weight + + alignment * self.alignment_weight + + cohesion * self.cohesion_weight + + goal_seek * self.goal_weight + ) + + return self.limit_force(total, self.max_force) + + def calculate_simple_forces(self, neighbors): + """Simplified: separation + goal only""" + separation = self.calculate_separation(neighbors) + goal_seek = self.calculate_goal_seek() + + total = separation * self.separation_weight + goal_seek * self.goal_weight + return self.limit_force(total, self.max_force) + + def calculate_minimal_forces(self): + """Minimal: goal only, no collision avoidance""" + return self.calculate_goal_seek() * self.goal_weight + + def calculate_separation(self, neighbors): + """Boids Rule 1: Avoid crowding""" + steering = np.array([0.0, 0.0]) + count = 0 + + for other in neighbors: + if other is self: + continue + + diff = self.position - other.position + distance = np.linalg.norm(diff) + + if 0 < distance < self.separation_radius: + # Weight by inverse distance (closer = stronger repulsion) + diff = diff / distance # Normalize + diff = diff / distance # Weight by 1/distance + steering += diff + count += 1 + + if count > 0: + steering /= count + steering = self.normalize(steering) * self.max_speed + steering -= self.velocity + + return steering + + def calculate_alignment(self, neighbors): + """Boids Rule 2: Align with neighbors""" + avg_velocity = np.array([0.0, 0.0]) + count = 0 + + for other in neighbors: + if other is self: + continue + + distance = np.linalg.norm(self.position - other.position) + if 0 < distance < self.alignment_radius: + avg_velocity += other.velocity + count += 1 + + if count > 0: + avg_velocity /= count + avg_velocity = self.normalize(avg_velocity) * self.max_speed + steering = avg_velocity - self.velocity + return steering + + return np.array([0.0, 0.0]) + + def calculate_cohesion(self, neighbors): + """Boids Rule 3: Move toward center of mass""" + center = np.array([0.0, 0.0]) + count = 0 + + for other in neighbors: + if other is self: + continue + + distance = np.linalg.norm(self.position - other.position) + if 0 < distance < self.cohesion_radius: + center += other.position + count += 1 + + if count > 0: + center /= count + desired = center - self.position + desired = self.normalize(desired) * self.max_speed + steering = desired - self.velocity + return steering + + return np.array([0.0, 0.0]) + + def calculate_goal_seek(self): + """Steer toward goal""" + to_goal = self.goal - self.position + distance = np.linalg.norm(to_goal) + + if distance < 0.1: + return np.array([0.0, 0.0]) # Reached goal + + desired = self.normalize(to_goal) * self.max_speed + steering = desired - self.velocity + + return steering + + @staticmethod + def normalize(vec): + """Normalize vector""" + norm = np.linalg.norm(vec) + if norm < 1e-6: + return vec + return vec / norm + + @staticmethod + def limit_force(force, max_force): + """Limit force magnitude""" + magnitude = np.linalg.norm(force) + if magnitude > max_force: + return (force / magnitude) * max_force + return force + + +# ============================================================================ +# LOD MANAGER (Fixes performance death from updating all agents) +# ============================================================================ + +class LODManager: + def __init__(self, camera_position): + self.camera_position = np.array(camera_position, dtype=float) + + # LOD distance thresholds + self.lod0_distance = 30.0 # Full simulation at 60 Hz + self.lod1_distance = 100.0 # Simple simulation at 30 Hz + self.lod2_distance = 200.0 # Minimal simulation at 10 Hz + # Beyond lod2: Frozen (no simulation) + + def update_lod_levels(self, agents): + """Assign LOD levels to agents based on distance""" + for agent in agents: + distance = np.linalg.norm(agent.position - self.camera_position) + + if distance < self.lod0_distance: + agent.lod_level = 0 + agent.update_frequency = 1 # Every frame + elif distance < self.lod1_distance: + agent.lod_level = 1 + agent.update_frequency = 2 # Every 2nd frame (30 Hz) + elif distance < self.lod2_distance: + agent.lod_level = 2 + agent.update_frequency = 6 # Every 6th frame (10 Hz) + else: + agent.lod_level = 3 # Frozen + agent.update_frequency = 9999 + + +# ============================================================================ +# CROWD SIMULATION (Main system) +# ============================================================================ + +class CrowdSimulation: + def __init__(self): + self.agents = [] + self.spatial_grid = SpatialHashGrid(cell_size=4.0) + self.lod_manager = None + self.frame_count = 0 + + def create_parade_crowd(self, num_agents=1000): + """Create parade scenario (addresses RED test)""" + print(f"Creating parade with {num_agents} agents...") + + # Create agents along parade route + for i in range(num_agents): + # Start positions (spread along route) + x = random.uniform(-20, 20) + y = i * 0.5 + position = (x, y) + + # Goal (end of parade route) + goal = (x * 0.5, 500) + + agent = CrowdAgent(position, goal) + self.agents.append(agent) + + # Create camera at center of crowd + center_y = (num_agents * 0.5) / 2 + self.lod_manager = LODManager(camera_position=(0, center_y)) + + print(f"Created {len(self.agents)} agents") + + def update(self, dt): + """Update simulation (60 FPS capable with 1000 agents)""" + self.frame_count += 1 + + # STEP 1: Update LOD levels + self.lod_manager.update_lod_levels(self.agents) + + # STEP 2: Rebuild spatial grid (O(n)) + self.spatial_grid.clear() + for agent in self.agents: + self.spatial_grid.insert(agent) + + # STEP 3: Update agents (O(n) with spatial grid) + for agent in self.agents: + # Query neighbors (O(1) average with spatial hash) + neighbors = self.spatial_grid.query_radius( + agent.position, + radius=max(agent.separation_radius, agent.alignment_radius, agent.cohesion_radius) + ) + + # Update agent + agent.update(neighbors, dt, self.frame_count) + + def get_performance_stats(self): + """Get LOD distribution for monitoring""" + lod_counts = {0: 0, 1: 0, 2: 0, 3: 0} + for agent in self.agents: + lod_counts[agent.lod_level] += 1 + + return { + "total_agents": len(self.agents), + "lod0_full": lod_counts[0], + "lod1_simple": lod_counts[1], + "lod2_minimal": lod_counts[2], + "lod3_frozen": lod_counts[3], + } + + +# ============================================================================ +# USAGE EXAMPLE +# ============================================================================ + +if __name__ == "__main__": + # Create simulation + sim = CrowdSimulation() + sim.create_parade_crowd(num_agents=1000) + + # Run simulation loop + import time + dt = 1.0 / 60.0 # 60 FPS + + for frame in range(600): # 10 seconds + start = time.time() + + sim.update(dt) + + elapsed = time.time() - start + + if frame % 60 == 0: + stats = sim.get_performance_stats() + print(f"Frame {frame}: {elapsed*1000:.2f}ms | LOD0: {stats['lod0_full']}, LOD1: {stats['lod1_simple']}, LOD2: {stats['lod2_minimal']}, LOD3: {stats['lod3_frozen']}") +``` + +**What This Fixes from RED Failures**: + +✅ **O(n²) Performance** → Spatial hash grid makes neighbor queries O(1) +✅ **No LOD** → 4-level LOD system (full, simple, minimal, frozen) +✅ **Missing Boids** → Full implementation (separation, alignment, cohesion) +✅ **Agents Overlapping** → Proper separation force with distance weighting +✅ **Robotic Movement** → Personality parameters (speed, personal space, risk tolerance) +✅ **No Formations** → (Pattern 2 adds formations) +✅ **Jerky Movement** → Smooth steering forces with acceleration limits +✅ **Cannot Scale** → Handles 1000 agents at 60 FPS with LOD +✅ **No Spatial Structure** → Spatial hash grid implemented +✅ **Uniform Agents** → Per-agent personality configuration + +**Performance**: +- 1000 agents: ~8-12ms per frame (60 FPS capable) +- Without spatial hash: ~500ms per frame (2 FPS) +- Speedup: 40-60× faster + + +### Pattern 2: Formation System for Groups + +Addresses RED failure: "No formation support - requirement explicitly states groups" + +```python +from enum import Enum +import numpy as np + +class FormationType(Enum): + LINE = "line" # X X X X (side by side) + COLUMN = "column" # X X X X (single file) + WEDGE = "wedge" # V-shape + CIRCLE = "circle" # Surround leader + SCATTER = "scatter" # Loose group + + +class Formation: + def __init__(self, leader, followers, formation_type=FormationType.LINE): + self.leader = leader + self.followers = followers + self.formation_type = formation_type + + # Formation parameters + self.slot_spacing = 1.2 # meters between slots + self.slot_tolerance = 2.0 # meters (how far from slot before breaking) + self.reform_distance = 5.0 # meters (if leader moves this far, reform) + + self.last_leader_pos = leader.position.copy() + + def update(self, dt): + """Update formation slots and follower steering""" + # Check if leader moved significantly (reform needed) + leader_moved = np.linalg.norm(self.leader.position - self.last_leader_pos) + if leader_moved > self.reform_distance: + self.last_leader_pos = self.leader.position.copy() + + # Calculate slot positions + slots = self.calculate_slots() + + # Assign followers to slots and steer toward them + for i, follower in enumerate(self.followers): + if i >= len(slots): + break + + target_slot = slots[i] + self.steer_to_slot(follower, target_slot) + + def calculate_slots(self): + """Calculate formation slot positions""" + # Leader's forward direction + if np.linalg.norm(self.leader.velocity) > 0.1: + forward = self.leader.velocity / np.linalg.norm(self.leader.velocity) + else: + forward = np.array([0.0, 1.0]) # Default forward + + right = np.array([-forward[1], forward[0]]) # Perpendicular + + slots = [] + + if self.formation_type == FormationType.LINE: + # Agents beside leader: X X L X X + mid = len(self.followers) // 2 + for i, follower in enumerate(self.followers): + offset_index = i - mid + slot = self.leader.position + right * (offset_index * self.slot_spacing) + slots.append(slot) + + elif self.formation_type == FormationType.COLUMN: + # Agents behind leader in single file: L X X X X + for i in range(len(self.followers)): + slot = self.leader.position - forward * ((i + 1) * self.slot_spacing) + slots.append(slot) + + elif self.formation_type == FormationType.WEDGE: + # V-shape: L + # X X + # X X + for i, follower in enumerate(self.followers): + row = (i // 2) + 1 + side = 1 if i % 2 == 0 else -1 + + slot = ( + self.leader.position - + forward * (row * self.slot_spacing) + + right * (side * row * self.slot_spacing * 0.7) + ) + slots.append(slot) + + elif self.formation_type == FormationType.CIRCLE: + # Circle around leader + num_followers = len(self.followers) + radius = self.slot_spacing * 2 + + for i in range(num_followers): + angle = (i / num_followers) * 2 * np.pi + offset = np.array([np.cos(angle), np.sin(angle)]) * radius + slot = self.leader.position + offset + slots.append(slot) + + elif self.formation_type == FormationType.SCATTER: + # Random offsets around leader (loose group) + for i in range(len(self.followers)): + random_offset = np.array([ + np.random.uniform(-2, 2), + np.random.uniform(-2, 2) + ]) * self.slot_spacing + slot = self.leader.position + random_offset + slots.append(slot) + + return slots + + def steer_to_slot(self, follower, target_slot): + """Override follower's goal to maintain formation""" + distance_to_slot = np.linalg.norm(target_slot - follower.position) + + if distance_to_slot > self.slot_tolerance: + # Too far from slot, prioritize catching up + follower.goal = target_slot + follower.goal_weight = 3.0 # Strong goal attraction + else: + # Close to slot, maintain formation + follower.goal = target_slot + follower.goal_weight = 1.5 # Normal goal attraction + + +class FormationManager: + """Manages multiple formations""" + def __init__(self): + self.formations = [] + + def create_family_formation(self, agents): + """Create family group (line formation)""" + if len(agents) < 2: + return None + + # Adult leads, children follow + leader = agents[0] + followers = agents[1:] + + formation = Formation(leader, followers, FormationType.LINE) + formation.slot_spacing = 0.8 # Tight spacing for family + self.formations.append(formation) + + return formation + + def create_march_formation(self, agents): + """Create marching column""" + if len(agents) < 4: + return None + + leader = agents[0] + followers = agents[1:] + + formation = Formation(leader, followers, FormationType.COLUMN) + formation.slot_spacing = 1.0 # Regular spacing + self.formations.append(formation) + + return formation + + def update_all(self, dt): + """Update all formations""" + for formation in self.formations: + formation.update(dt) + + +# Usage: Add to CrowdSimulation +class CrowdSimulationWithFormations(CrowdSimulation): + def __init__(self): + super().__init__() + self.formation_manager = FormationManager() + + def create_parade_with_groups(self, num_agents=1000): + """Create parade with 30% in family groups""" + self.create_parade_crowd(num_agents) + + # Group 30% of agents into families + ungrouped = self.agents.copy() + random.shuffle(ungrouped) + + while len(ungrouped) >= 3: + # Take 2-4 agents for family + family_size = random.randint(2, 4) + family = ungrouped[:family_size] + ungrouped = ungrouped[family_size:] + + # Create formation + self.formation_manager.create_family_formation(family) + + def update(self, dt): + """Update simulation + formations""" + super().update(dt) + self.formation_manager.update_all(dt) +``` + + +### Pattern 3: Doorway Flow Control (Edge Case Handling) + +Addresses RED failure: "Doorway gridlock - edge case handling missing" + +```python +class DoorwayManager: + """Manages flow through narrow passages to prevent gridlock""" + def __init__(self, doorway_position, doorway_width, flow_direction): + self.position = np.array(doorway_position) + self.width = doorway_width + self.flow_direction = np.array(flow_direction) # Direction through door + + # Queue management + self.queue_distance = 5.0 # Start queueing 5m before door + self.max_flow_rate = 1.5 # agents per second + self.last_agent_passed = 0.0 + + # Waiting area + self.waiting_slots = self.create_waiting_slots() + + def create_waiting_slots(self): + """Create queue positions before doorway""" + slots = [] + perpendicular = np.array([-self.flow_direction[1], self.flow_direction[0]]) + + # 3 columns, 10 rows + for row in range(10): + for col in range(-1, 2): + slot = ( + self.position - + self.flow_direction * (row + 1) * 0.8 + # Behind door + perpendicular * col * 0.6 # Side-to-side + ) + slots.append(slot) + + return slots + + def manage_flow(self, agents, current_time): + """Control agent flow through doorway""" + nearby_agents = [ + a for a in agents + if np.linalg.norm(a.position - self.position) < self.queue_distance + ] + + # Sort by distance to doorway (closest first) + nearby_agents.sort( + key=lambda a: np.linalg.norm(a.position - self.position) + ) + + for i, agent in enumerate(nearby_agents): + distance_to_door = np.linalg.norm(agent.position - self.position) + + if distance_to_door < 1.0: + # At doorway + # Check if allowed to pass (flow rate limiting) + time_since_last = current_time - self.last_agent_passed + min_interval = 1.0 / self.max_flow_rate + + if time_since_last >= min_interval: + # Allow passage + agent.goal = self.position + self.flow_direction * 3.0 + self.last_agent_passed = current_time + else: + # Must wait, assign to queue slot + if i < len(self.waiting_slots): + agent.goal = self.waiting_slots[i] + agent.max_speed *= 0.5 # Slow down in queue + else: + # Approaching doorway, move toward it + agent.goal = self.position +``` + + +## Common Pitfalls + +### Pitfall 1: O(n²) Neighbor Queries (Performance Death) + +**The Mistake**: +```python +# ❌ DISASTER: Every agent checks every other agent +def update_agent(agent, all_agents): + for other in all_agents: + if other == agent: + continue + distance = (agent.position - other.position).length() + if distance < 3.0: + # Avoid... + pass + +# 1000 agents × 1000 checks = 1,000,000 checks per frame +# At 60 FPS: 60,000,000 checks per second +# Result: 0.5 FPS (slideshow) +``` + +**Why This Fails**: +- **Complexity**: O(n²) - grows quadratically with agent count +- **100 agents**: 10,000 checks (manageable) +- **1000 agents**: 1,000,000 checks (death) +- **Wasted work**: 95% of checks are for distant agents that don't matter + +**The Fix**: Spatial Hash Grid + +```python +# ✅ CORRECT: Use spatial structure +class SpatialHashGrid: + # ... (see Pattern 1) + +spatial_grid = SpatialHashGrid(cell_size=3.0) + +# Rebuild grid each frame (O(n)) +spatial_grid.clear() +for agent in agents: + spatial_grid.insert(agent) + +# Query neighbors (O(1) average) +neighbors = spatial_grid.query_radius(agent.position, radius=3.0) + +# 1000 agents × ~10 neighbors = 10,000 checks per frame +# At 60 FPS: 600,000 checks per second +# Speedup: 100× faster! +``` + +**Performance Math**: +``` +Without spatial structure: O(n²) +- 1000 agents: 1,000,000 checks + +With spatial hash (cell size = 2 × query radius): +- 1000 agents: ~10,000 checks (each checks ~10 neighbors) +- Speedup: 100× + +With spatial hash (badly tuned cell size): +- Cell too small: Many cells to check → O(n) still +- Cell too large: Many agents per cell → O(n²) within cells +- Rule of thumb: cell_size = 2 × neighbor_radius +``` + +**Red Flags**: +- Double nested loop over all agents +- `for agent in agents: for other in agents:` +- No spatial data structure visible +- Performance degrades rapidly with more agents + + +### Pitfall 2: No LOD System (All Agents Full Detail) + +**The Mistake**: +```python +# ❌ All 1000 agents run full simulation every frame +for agent in agents: + neighbors = find_neighbors(agent) # Expensive + agent.update_boids(neighbors) # Expensive + agent.update_rvo(neighbors) # Very expensive + agent.calculate_path() # Extremely expensive + +# Every agent gets same CPU time regardless of: +# - Distance from camera (500m away = same as 5m away) +# - Visibility (off-screen = same as on-screen) +# - Importance (background NPC = same as hero) + +# Result: 50ms per frame (20 FPS) with 1000 agents +``` + +**Why This Fails**: +- **Player can only see ~100 agents** at once (60° FOV, distance culling) +- **Simulating 900 invisible agents** at full detail +- **Wasted CPU**: 90% of compute on agents player can't see +- **Cannot scale**: Adding more agents linearly degrades FPS + +**The Fix**: Multi-Level LOD + +```python +# ✅ CORRECT: LOD system based on distance/visibility +class LODManager: + def update_agent_lod(self, agent, camera_pos): + distance = (agent.position - camera_pos).length() + + if distance < 30: + # LOD 0: Full simulation (60 Hz) + agent.lod_level = 0 + agent.update_frequency = 1 # Every frame + elif distance < 100: + # LOD 1: Simple simulation (30 Hz) + agent.lod_level = 1 + agent.update_frequency = 2 # Every 2nd frame + elif distance < 200: + # LOD 2: Minimal simulation (10 Hz) + agent.lod_level = 2 + agent.update_frequency = 6 # Every 6th frame + else: + # LOD 3: Frozen (no simulation) + agent.lod_level = 3 + +# Update with LOD awareness +if frame % agent.update_frequency == 0: + if agent.lod_level == 0: + agent.update_full() # Boids + RVO + pathfinding + elif agent.lod_level == 1: + agent.update_simple() # Boids separation only + elif agent.lod_level == 2: + agent.update_minimal() # Follow flow field only + # LOD 3: No update + +# Result: 8ms per frame (60 FPS) with 1000 agents! +``` + +**LOD Performance Impact**: +``` +1000 agents, camera in center of crowd: + +Without LOD: +- 1000 agents × 0.05ms (full sim) = 50ms per frame +- FPS: 20 (unplayable) + +With 4-level LOD: +- 50 agents × 0.05ms (LOD 0, full sim) = 2.5ms +- 200 agents × 0.025ms (LOD 1, simple sim at 30 Hz) = 2.5ms +- 400 agents × 0.01ms (LOD 2, minimal sim at 10 Hz) = 1.3ms +- 350 agents × 0ms (LOD 3, frozen) = 0ms +- Total: 6.3ms per frame +- FPS: 60+ (playable!) + +Speedup: 8× faster +``` + +**When LOD is Mandatory**: +- **< 50 agents**: Optional (can run full sim at 60 FPS) +- **50-200 agents**: Recommended (rendering bottleneck) +- **200-1000 agents**: Required (simulation bottleneck) +- **1000+ agents**: Absolutely mandatory + GPU consideration + + +### Pitfall 3: Missing Boids Algorithm (Unrealistic Crowd Flow) + +**The Mistake**: +```python +# ❌ Only simple goal-seeking, no crowd dynamics +def update_agent(agent): + # Move toward goal + direction = (agent.goal - agent.position).normalized() + agent.position += direction * agent.speed * dt + + # Simple collision: push away if overlapping + for other in nearby_agents: + if overlapping(agent, other): + push_apart(agent, other) + +# Result: +# - Agents spread out randomly (no cohesion) +# - Everyone goes different direction (no alignment) +# - Constant collisions and bouncing (poor separation) +# - Looks like ants, not humans +``` + +**Why This Fails**: +- **No emergence**: Crowd behavior doesn't emerge from local rules +- **No flow**: Agents don't form natural streams +- **Reactive only**: Only responds after collision, no anticipation +- **Looks fake**: Players immediately notice unrealistic behavior + +**The Fix**: Implement Boids + +```python +# ✅ CORRECT: Full Boids implementation +def update_agent(agent, neighbors): + # Rule 1: Separation (avoid crowding) + separation = calculate_separation(agent, neighbors) + + # Rule 2: Alignment (match neighbor velocity) + alignment = calculate_alignment(agent, neighbors) + + # Rule 3: Cohesion (stay with group) + cohesion = calculate_cohesion(agent, neighbors) + + # Goal seeking (where agent wants to go) + goal_seek = calculate_goal_seek(agent) + + # Combine forces with weights + total_force = ( + separation * 1.5 + # Strongest (avoid collisions) + alignment * 1.0 + + cohesion * 1.0 + + goal_seek * 1.2 + ) + + # Update velocity (steering) + agent.velocity += total_force * dt + agent.velocity = agent.velocity.limit(agent.max_speed) + agent.position += agent.velocity * dt + +# Result: +# - Natural crowd flow and lanes +# - Groups stay together +# - Smooth avoidance (no bouncing) +# - Looks realistic +``` + +**Boids Impact on Realism**: + +| Without Boids | With Boids | +|---------------|------------| +| Random scatter | Natural clustering | +| Chaotic directions | Uniform flow | +| Constant collisions | Smooth avoidance | +| Agents spread out | Groups stay together | +| Looks like ants | Looks like humans | + +**Tuning for Different Crowds**: + +```python +# Panicked crowd (evacuation) +separation_weight = 0.8 # Less personal space +alignment_weight = 1.5 # Follow crowd strongly +cohesion_weight = 0.5 # Don't care about staying together +goal_weight = 2.0 # Urgently reach exit + +# Casual stroll (park) +separation_weight = 2.0 # More personal space +alignment_weight = 0.8 # Don't care about others' direction +cohesion_weight = 1.2 # Stay with friends/family +goal_weight = 1.0 # Leisurely pace + +# Military march +separation_weight = 1.5 # Maintain spacing +alignment_weight = 3.0 # Perfect synchronization +cohesion_weight = 2.0 # Tight formation +goal_weight = 1.5 # Follow route +``` + + +### Pitfall 4: Agents Overlapping (Insufficient Separation) + +**The Mistake**: +```python +# ❌ Weak separation force +def calculate_separation(agent, neighbors): + steering = Vector2(0, 0) + + for other in neighbors: + distance = (agent.position - other.position).length() + if distance < 2.0: + # Push away + diff = agent.position - other.position + steering += diff # NOT weighted by distance! + + return steering + +# Result: +# - Close agents don't repel strongly enough +# - Agents overlap and clip through each other +# - Visual artifacts (heads inside bodies) +``` + +**Why This Fails**: +- **Not weighted by distance**: Agent 0.1m away has same force as agent 1.9m away +- **Too weak**: Force magnitude too small to overcome goal-seeking +- **No emergency handling**: Doesn't handle already-overlapping agents + +**The Fix**: Distance-Weighted Separation + Emergency Mode + +```python +# ✅ CORRECT: Distance-weighted separation with emergency handling +def calculate_separation(agent, neighbors): + steering = Vector2(0, 0) + count = 0 + + for other in neighbors: + diff = agent.position - other.position + distance = diff.length() + + if distance < 0.01: + # Emergency: Already overlapping badly + # Apply maximum repulsion in random direction + angle = random.uniform(0, 2 * math.pi) + emergency_force = Vector2(math.cos(angle), math.sin(angle)) + return emergency_force * agent.max_force * 5.0 # STRONG force + + if 0 < distance < agent.separation_radius: + # Weight by inverse distance (closer = stronger) + # 1/distance means: 0.5m away → 2× force, 0.25m → 4× force + diff = diff.normalized() / distance + steering += diff + count += 1 + + if count > 0: + steering /= count + + # Calculate steering force + steering = steering.normalized() * agent.max_speed + steering -= agent.velocity + steering = steering.limit(agent.max_force) + + return steering + +# Also: Increase separation weight +separation_force = calculate_separation(agent, neighbors) +total_force = separation_force * 2.0 + other_forces # Strong weight! +``` + +**Separation Tuning**: + +```python +# Too weak (agents overlap) +separation_weight = 0.5 +separation_radius = 1.0 + +# Good (no overlap) +separation_weight = 1.5 +separation_radius = 2.0 + +# Too strong (agents avoid too much, sparse crowd) +separation_weight = 5.0 +separation_radius = 5.0 +``` + +**Visual Check**: +- Draw agent collision circles in debug mode +- **Red flag**: Circles overlapping → separation too weak +- **Good**: Circles touch but don't overlap +- **Too strong**: Large gaps between agents + + +### Pitfall 5: Ignoring Edge Cases (Doorways, Corners, Dead Ends) + +**The Mistake**: +```python +# ❌ Only handles open space scenarios +# No special handling for: +# - Narrow doorways (agents pile up, gridlock) +# - Corners (agents get stuck) +# - Dead ends (agents never escape) +# - Stairs/slopes (agents slide) + +# Result: +# - Simulation breaks in constrained spaces +# - Players notice immediately (very visible) +# - Requires manual intervention or restart +``` + +**Why This Fails**: +- **Boids assumes open space**: Works great in fields, breaks in buildings +- **No flow control**: All agents try to push through door simultaneously +- **No deadlock detection**: Agents wait forever in corners +- **No escape behavior**: Can't back out of dead ends + +**The Fix**: Special Case Handlers + +```python +# ✅ CORRECT: Edge case handling + +# 1. Doorway Manager (prevents gridlock) +class DoorwayManager: + def manage_flow(self, agents, doorway): + # Limit flow rate (1.5 agents/second) + # Create queue before doorway + # Assign queue slots to waiting agents + # See Pattern 3 for full implementation + +# 2. Corner Escape +def check_stuck_in_corner(agent): + if agent.stuck_timer > 3.0: # Stuck for 3 seconds + # Check if in corner (velocity near zero, goal far away) + speed = agent.velocity.length() + distance_to_goal = (agent.goal - agent.position).length() + + if speed < 0.1 and distance_to_goal > 5.0: + # Stuck! Add random wander to escape + random_direction = Vector2( + random.uniform(-1, 1), + random.uniform(-1, 1) + ).normalized() + + agent.velocity += random_direction * agent.max_speed * 0.5 + agent.stuck_timer = 0 # Reset + +# 3. Dead End Detection +def detect_dead_end(agent): + # Raycast toward goal + ray_hit = raycast(agent.position, agent.goal) + + if ray_hit.hit_wall: + # Check if alternate path exists + alternate = find_alternate_path(agent.position, agent.goal) + + if not alternate: + # Dead end! Find nearest open area + agent.goal = find_nearest_open_space(agent.position) + +# 4. Panic Mode (evacuations) +def handle_panic(agent, threat_position): + # In panic: + # - Increase speed (1.3× normal) + # - Reduce personal space (0.5× normal) + # - Follow crowd more strongly (alignment weight 2×) + # - Ignore politeness (push through) + + agent.max_speed *= 1.3 + agent.separation_radius *= 0.5 + agent.alignment_weight *= 2.0 +``` + +**Edge Cases Checklist**: +- [ ] Doorways (flow control) +- [ ] Narrow corridors (queue management) +- [ ] Corners (stuck detection + escape) +- [ ] Dead ends (alternate path finding) +- [ ] Stairs/slopes (prevent sliding) +- [ ] Moving obstacles (dynamic avoidance) +- [ ] Panic/stampede (reduced personal space) + + +## Real-World Examples + +### Example 1: Assassin's Creed Unity - 10,000 NPC Crowds + +**Scale**: 10,000+ NPCs in Paris streets, Revolution-era protests and riots. + +**Technical Approach**: + +1. **5-Level LOD System**: + - **LOD 0** (< 10m): Full skeleton, cloth sim, unique animations + - **LOD 1** (10-30m): Simplified skeleton, baked cloth, shared anims + - **LOD 2** (30-100m): Low-poly mesh, 3-bone skeleton + - **LOD 3** (100-200m): Impostor (billboard sprite) + - **LOD 4** (> 200m): Culled (not rendered) + +2. **Simulation LOD**: + - Close NPCs: Full AI (behavior tree, pathfinding, reactions) + - Medium NPCs: Simplified (scripted paths, basic avoidance) + - Far NPCs: Animated sprites (no simulation) + +3. **Crowd Behaviors**: + - **Normal state**: Boids with goal-seeking (shops, homes, work) + - **Riot state**: Increased density, following flow fields toward objectives + - **Panic state**: Fleeing from threats (player, guards, explosions) + +4. **Performance Optimizations**: + - Spatial hashing for collision queries + - GPU instancing for rendering (1000+ NPCs per draw call) + - Async pathfinding (time-sliced over 5 frames) + - Shared animation state machines + +**Result**: 30-60 FPS with 10,000 NPCs on PS4/Xbox One. + +**Key Lesson**: LOD is mandatory at this scale. Visual LOD reduces rendering cost 100×, behavior LOD reduces simulation cost 50×. + + +### Example 2: Total War: Three Kingdoms - Formation Battles + +**Scale**: 10,000+ soldiers in tight formations, real-time battles. + +**Technical Approach**: + +1. **Unit-Based Architecture**: + - Units of 100-200 soldiers treated as single entity + - Formation shape (square, wedge, circle) assigned to unit + - Individual soldiers maintain slot in formation + +2. **Formation Maintenance**: + ``` + Each soldier: + - Assigned slot position relative to unit center + - Steers toward slot (90% of force) + - Avoids nearby soldiers (10% of force) + - If pushed > 5m from slot, breaks formation and catches up + ``` + +3. **Combat Simulation**: + - Only front-rank soldiers engage (20-30 per unit) + - Back ranks wait in formation + - When front soldier dies, back soldier moves forward + +4. **LOD System**: + - **Player's camera focus**: Full simulation + high-detail mesh + - **Same screen but distant**: Simplified simulation + medium mesh + - **Off-screen**: Scripted combat (no individual simulation) + low mesh + +**Result**: 20,000 soldiers at 40-60 FPS on mid-range PCs. + +**Key Lesson**: Formation system allows large armies while keeping simulation tractable. Treating 100 soldiers as 1 unit reduces simulation cost 100×. + + +### Example 3: Cities: Skylines - Traffic and Pedestrian Simulation + +**Scale**: 1 million+ citizens (not all active), 100,000+ active vehicles/pedestrians. + +**Technical Approach**: + +1. **Citizen Lifecycle**: + - Most citizens simulated abstractly (not rendered) + - Only citizens in player's viewport rendered and simulated + - When leaving viewport, citizen converted to abstract state + +2. **Pathfinding**: + - Hierarchical pathfinding (highways → arterials → local streets) + - Path caching (common routes stored, reused) + - Time-sliced (10ms budget per frame, rest queued) + +3. **Crowd Movement**: + - Pedestrians use Boids in dense areas (parks, plazas) + - Sidewalks use scripted flow (follow path, no collision) + - Crosswalks have flow managers (queue, wait for signal) + +4. **Performance**: + - Only ~1000 agents actively simulated at once + - Rest in "hibernation" (position updated, no collision/avoidance) + - Transition from hibernation → active as camera approaches + +**Result**: Simulate cities of 1M+ citizens with 10,000 active agents at 30-60 FPS. + +**Key Lesson**: Most agents don't need full simulation. Aggressive culling and hibernation allow huge scale. + + +### Example 4: Half-Life: Alyx - Dense Urban Crowds (VR) + +**Scale**: 50-100 NPCs in dense city streets (City 17). + +**Technical Approach**: + +1. **VR Constraints**: + - Must maintain 90 FPS (VR sickness if drops) + - Higher render cost than flat games + - CPU budget: 8ms per frame for ALL simulation + +2. **Optimization for VR**: + - Strict agent limit (max 100 active) + - Aggressive LOD (3 levels, tight thresholds) + - No distant crowds (culled aggressively) + +3. **Quality Focus**: + - Few agents, but high quality + - Full RVO (collision-free movement) + - Individual personalities (speed, animation, reactions) + - High-detail meshes even at medium distance + +4. **Scripted vs Simulated**: + - 70% of crowd is scripted (walk on rails) + - 30% fully simulated (respond to player) + - Scripted NPCs transition to simulated when player interacts + +**Result**: 50-100 NPCs at 90 FPS in VR. + +**Key Lesson**: VR requires strict performance budgets. Quality over quantity - fewer agents with better simulation is more believable. + + +### Example 5: Crowd Simulation for Safety Analysis (Real-World) + +**Use Case**: Stadium evacuation planning, subway crowd management. + +**Technical Approach**: + +1. **Validation Against Reality**: + - Calibrate agent parameters using real crowd data + - Match observed densities, flow rates, exit times + - Academic rigor: Social Forces model (Helbing et al.) + +2. **Agent Heterogeneity**: + - Elderly: Slower speed (0.8 m/s vs 1.4 m/s average) + - Children: Smaller personal space, follow parents + - Mobility-impaired: Wheelchairs, crutches (0.5 m/s) + - Distribution: 10% elderly, 5% children, 2% mobility-impaired + +3. **Panic Modeling**: + - Normal: Personal space 0.8m, orderly queues + - Stress: Personal space 0.5m, pushing increases + - Panic: Personal space 0.2m, stampede risk + +4. **Bottleneck Analysis**: + - Identify chokepoints (doorways, stairs) + - Measure flow rates (agents/second) + - Detect crush risks (density > 5.5 people/m²) + +**Result**: Accurate evacuation time predictions (±10% of real drills). + +**Key Lesson**: Realism matters for safety. Must validate against real-world data, not just "looks good." + + +## Cross-References + +### Related Skills + +**[Traffic and Pathfinding]** (same skillpack): +- Flow fields for crowd movement to same goal +- Hierarchical pathfinding for large environments +- Spatial partitioning techniques (spatial hash, quadtree) +- Time-sliced pathfinding (async requests) + +**[AI and Agent Simulation]** (same skillpack): +- Behavior trees for agent decision-making +- State machines for agent states (idle, walking, panicked) +- Steering behaviors (seek, flee, wander) +- Sensor systems (vision, hearing) for reactive agents + +**[Physics Simulation Patterns]** (same skillpack): +- Collision detection and response +- Spatial partitioning for physics queries +- Performance optimization (sleeping, broad phase) + +**[Performance Optimization]** (adjacent skillpack): +- Profiling crowd simulation bottlenecks +- Memory pooling for agents +- Cache-friendly data structures (SoA vs AoS) +- Multi-threading crowd updates (job systems) + +### External Resources + +**Academic**: +- "Boids: Flocks, Herds, and Schools" - Craig Reynolds (1987) - Original boids paper +- "Social Force Model for Pedestrian Dynamics" - Helbing & Molnar (1995) +- "Reciprocal Velocity Obstacles" - van den Berg et al. (2008) +- "Continuum Crowds" - Treuille et al. (2006) - Flow field approach + +**Tools & Libraries**: +- Unity ML-Agents: Crowd simulation with machine learning +- RVO2 Library: Open-source RVO implementation +- MomenTUM: Multi-model crowd simulation framework (research) + +**Industry Resources**: +- GDC talks: "Crowds in Assassin's Creed", "Total War AI" +- Unity DOTS samples: Massive crowd simulation examples +- Unreal Engine: Crowd following system documentation + + +## Testing Checklist + +### Performance Tests + +- [ ] **Frame rate**: 60 FPS with 1000 agents (RED test requirement) +- [ ] **Scaling**: Test 100, 500, 1000, 2000 agents - should degrade gracefully +- [ ] **LOD distribution**: Monitor LOD levels (should be pyramid: few LOD0, many LOD3) +- [ ] **Profiling**: < 8ms per frame for simulation (use profiler) +- [ ] **Spatial queries**: Neighbor queries take < 0.01ms per agent + +### Correctness Tests + +- [ ] **No overlapping**: Agents don't clip through each other (draw collision circles) +- [ ] **Flow formation**: Crowds form natural lanes and streams +- [ ] **Group cohesion**: Formation groups stay together +- [ ] **Goal reaching**: All agents eventually reach goal (no infinite loops) +- [ ] **Dynamic obstacles**: Agents avoid moving obstacles + +### Behavior Tests + +- [ ] **Boids emergence**: Visible flocking behavior (not random scatter) +- [ ] **Personal space**: Agents maintain comfortable distance +- [ ] **Panic mode**: Increased speed and density during evacuation +- [ ] **Formation integrity**: Groups maintain formation shape +- [ ] **Personality variation**: Visible speed/spacing differences + +### Edge Case Tests + +- [ ] **Doorway flow**: Agents form queue, no gridlock at narrow passages +- [ ] **Corner escape**: Stuck agents escape after 3-5 seconds +- [ ] **Dense crowds**: Simulation stable at 5+ agents per square meter +- [ ] **Empty space**: Agents don't cluster unnecessarily in open areas +- [ ] **Goal reached**: Agents idle or loop appropriately at destination + +### Quality Tests + +- [ ] **Visual smoothness**: No jerky movement or sudden direction changes +- [ ] **Noise**: Add debug audio (footsteps scaled by visible agent count) +- [ ] **Animation blending**: Walking speed matches velocity magnitude +- [ ] **Responsive**: Changes to goals/threats visible within 0.5 seconds +- [ ] **Believable**: Playtesters don't notice AI issues + +### Robustness Tests + +- [ ] **Long running**: No degradation after 1 hour +- [ ] **Stress test**: 2× target agent count (should degrade, not crash) +- [ ] **Rapid spawning**: Spawn 1000 agents in 1 frame (no hitch) +- [ ] **Pathological cases**: All agents same start/goal (worst case density) + + +## Summary + +**Crowd simulation is about emergent behavior from simple local rules**: +- **Boids three rules** → Natural flocking without central coordination +- **Spatial partitioning** → O(1) neighbor queries instead of O(n²) +- **LOD hierarchy** → Only simulate what matters (near agents full detail, far agents frozen) +- **Edge case handling** → Special logic for doorways, corners, panic + +**The most critical insight**: **You're not simulating 1000 individuals, you're simulating a crowd system.** Treat it as a system with spatial optimization, not 1000 separate agents. + +**When implementing**: +1. Start with spatial hash (fixes O(n²) immediately) +2. Add basic Boids (separation, alignment, cohesion) +3. Add LOD system (3-4 levels based on distance) +4. Add formations (groups stay together) +5. Handle edge cases (doorways, corners) +6. Add personality variation (speed, spacing) + +**Performance is non-negotiable**: If you can't hit 60 FPS with 1000 agents, you've failed the requirement. LOD + spatial hash are mandatory, not optional. + +**Test at scale early**: 10 agents works very differently than 1000 agents. Don't wait until beta to test performance. + +**The 80/20 rule**: Get 80% of realism with 20% of effort. Boids + spatial hash + 3-level LOD gives you production-quality crowds. Anything beyond that (RVO, formations, personalities) is polish. + +**Architecture matters**: Separate simulation from rendering. Agent shouldn't know HOW it's rendered, only that it has a position/velocity. This allows swapping rendering (mesh vs impostor vs GPU instanced) without changing simulation. diff --git a/skills/using-simulation-tactics/debugging-simulation-chaos.md b/skills/using-simulation-tactics/debugging-simulation-chaos.md new file mode 100644 index 0000000..bd39f79 --- /dev/null +++ b/skills/using-simulation-tactics/debugging-simulation-chaos.md @@ -0,0 +1,2262 @@ + +# Debugging Simulation Chaos + +## Description +Master deterministic simulation, replay systems, debug visualization, chaos identification, and systematic debugging methodologies for game simulations. Build deterministic multiplayer simulations where clients stay perfectly in sync. Implement replay recording, debug visualization tools, and rigorous testing strategies. Understand butterfly effects, floating-point non-determinism, and order-dependence. Apply scientific method to debugging: reproduce, isolate, hypothesize, test, verify. + +## When to Use This Skill +Use this skill when debugging or implementing: +- Multiplayer game simulations (physics, ecosystems, AI) that must stay in sync +- Replay systems (save game state and inputs for playback) +- Non-reproducible bugs in simulations (works sometimes, fails other times) +- Desync issues (clients see different simulation states) +- "Works on my machine" problems (platform-specific non-determinism) +- Chaotic systems (small input changes cause massive output differences) +- Emergent bugs (simulation stable for hours, then suddenly collapses) + +Do NOT use this skill for: +- Simple single-player games with no replay requirements (determinism not critical) +- Purely cosmetic/visual bugs (animation glitches, rendering issues) +- Network latency/packet loss problems (this is about simulation determinism, not networking) +- Intentionally random gameplay (roguelikes where variance is desired) + + +## Quick Start (Emergency Desync Debugging) + +If you have a critical multiplayer desync bug and need to debug NOW (< 4 hours): + +**CRITICAL (Do This First)**: +1. **Build minimal replay system** - Record inputs, replay identical sequence +2. **Add state checksums** - Hash simulation state every frame, compare between clients +3. **Identify divergence point** - Binary search to find first frame where states differ +4. **Check common culprits**: + - Unseeded `Math.random()` → Use seeded RNG + - `Map`/`Set` iteration → Sort keys before iterating + - Floating point accumulation → Use fixed-point or integer math for critical values + - `Date.now()` / timing → Use simulation ticks, not wall-clock time + +**IMPORTANT (Within First Hour)**: +5. Add debug visualization (draw entity positions, show state values) +6. Write unit test that reproduces bug (given same inputs → same outputs) +7. Binary search the problem (comment out subsystems until desync disappears) +8. Add assertions (check invariants every frame, fail fast on divergence) + +**CAN DEFER** (After Fix): +- Full replay UI with scrubbing/playback controls +- Property-based testing for all invariants +- Comprehensive debug draw system +- Performance profiling tools + +**Example - Emergency Desync Fix in 2 Hours**: +```python +import hashlib +import json +import random + +# 1. DETERMINISTIC RNG (seed per simulation) +class SeededRandom: + def __init__(self, seed): + self.rng = random.Random(seed) + + def random(self): + return self.rng.random() + + def randint(self, a, b): + return self.rng.randint(a, b) + +# 2. STATE CHECKSUM (detect divergence immediately) +def checksum_state(simulation): + """Hash simulation state to detect desyncs""" + # Sort all dictionaries/sets to ensure consistent order + state = { + 'deer': sorted([(d.id, d.x, d.y, d.energy) for d in simulation.deer]), + 'wolves': sorted([(w.id, w.x, w.y, w.energy) for w in simulation.wolves]), + 'grass': simulation.grass, + 'tick': simulation.tick + } + state_json = json.dumps(state, sort_keys=True) + return hashlib.sha256(state_json.encode()).hexdigest() + +# 3. REPLAY SYSTEM (reproduce bug) +class ReplaySystem: + def __init__(self): + self.recorded_inputs = [] + self.is_recording = True + self.is_replaying = False + self.replay_index = 0 + + def record_input(self, input_event): + """Record input during normal gameplay""" + if self.is_recording: + self.recorded_inputs.append({ + 'tick': input_event['tick'], + 'action': input_event['action'], + 'data': input_event['data'] + }) + + def start_replay(self): + """Start replaying recorded inputs""" + self.is_replaying = True + self.is_recording = False + self.replay_index = 0 + + def get_next_input(self, current_tick): + """Get next input event if available""" + if not self.is_replaying or self.replay_index >= len(self.recorded_inputs): + return None + + next_input = self.recorded_inputs[self.replay_index] + if next_input['tick'] == current_tick: + self.replay_index += 1 + return next_input + return None + + def save_replay(self, filename): + """Save replay to file""" + with open(filename, 'w') as f: + json.dump(self.recorded_inputs, f) + + def load_replay(self, filename): + """Load replay from file""" + with open(filename, 'r') as f: + self.recorded_inputs = json.load(f) + +# 4. DETERMINISTIC SIMULATION +class DeterministicEcosystem: + def __init__(self, seed): + self.rng = SeededRandom(seed) + self.tick = 0 + self.deer = [] + self.wolves = [] + self.grass = 1000.0 + self.replay = ReplaySystem() + + # Initialize with deterministic entity IDs + for i in range(10): + self.deer.append(Deer(id=i, x=i*10.0, y=0.0, energy=100.0)) + + def step(self, dt=1.0): + """One simulation step - MUST be deterministic""" + self.tick += 1 + + # Process in deterministic order (sorted by ID) + self.deer.sort(key=lambda d: d.id) + self.wolves.sort(key=lambda w: w.id) + + # Update grass (no randomness) + self.grass += 5.0 * dt + self.grass = min(1000.0, self.grass) + + # Update deer (deterministic) + for deer in self.deer: + # Use seeded RNG for any randomness + if self.rng.random() < 0.1: + deer.reproduce() + + # Checksum validation (detect desyncs immediately) + checksum = checksum_state(self) + print(f"Tick {self.tick}: Checksum = {checksum[:8]}") + + return checksum + +# 5. UNIT TEST (reproducibility) +def test_determinism(): + """Verify simulation is deterministic""" + # Run simulation twice with same seed + sim1 = DeterministicEcosystem(seed=42) + sim2 = DeterministicEcosystem(seed=42) + + for _ in range(100): + checksum1 = sim1.step() + checksum2 = sim2.step() + + assert checksum1 == checksum2, f"DESYNC at tick {sim1.tick}!" + + print("✓ Determinism test passed - 100 ticks identical") + +# Run test +test_determinism() +``` + +**This gives you:** +- ✅ Deterministic RNG (seeded per simulation) +- ✅ State checksums (detect desyncs immediately) +- ✅ Replay system (reproduce bugs) +- ✅ Deterministic ordering (sort before iterating) +- ✅ Unit test (verify determinism) + + +## Core Concepts + +### 1. Determinism in Simulations + +**What:** A deterministic simulation produces the exact same outputs given the same inputs and initial state. No variance, no "close enough" - bit-for-bit identical. + +**Why Critical:** +- **Multiplayer sync:** Clients run same simulation, stay in sync +- **Replay systems:** Record inputs, replay produces identical results +- **Testing:** Bugs reproducible, not random "sometimes happens" +- **Debugging:** Can bisect to find exact tick where bug occurs + +**Sources of Non-Determinism (ALL must be eliminated):** + +#### 1.1 Random Number Generation +```python +# ❌ NON-DETERMINISTIC (uses system entropy) +import random +x = random.random() # Different on each client! + +# ✅ DETERMINISTIC (seeded RNG) +class SeededRandom: + def __init__(self, seed): + self.rng = random.Random(seed) + + def random(self): + return self.rng.random() + +# Usage +rng = SeededRandom(seed=12345) # Same seed on all clients +x = rng.random() # Identical on all clients +``` + +**Key Insight:** Global `random.seed()` is NOT enough - need separate RNG instance per simulation (multiple simulations might run in same process). + +#### 1.2 Map/Set Iteration Order +```python +# ❌ NON-DETERMINISTIC (iteration order undefined) +entities = {'wolf_1': wolf1, 'deer_2': deer2, 'wolf_3': wolf3} +for entity_id, entity in entities.items(): + entity.update() # Order varies between runs! + +# ✅ DETERMINISTIC (sort before iterating) +for entity_id in sorted(entities.keys()): + entities[entity_id].update() # Always same order +``` + +**Languages affected:** Python, JavaScript, Java (HashMap), C++ (unordered_map) + +#### 1.3 Floating Point Accumulation +```python +# ❌ NON-DETERMINISTIC (accumulation errors differ) +position = 0.0 +for _ in range(1000): + position += 0.1 # Accumulates rounding errors differently on platforms + +# ✅ DETERMINISTIC (fixed-point or integer math) +position_int = 0 # Store as integer (units = 0.01) +for _ in range(1000): + position_int += 10 # Add 0.1 as integer +position = position_int / 100.0 # Convert back +``` + +**Example:** x86 vs ARM might compute `0.1 + 0.2` with different rounding. After 10,000 operations, divergence is massive. + +#### 1.4 Timing Dependencies +```python +# ❌ NON-DETERMINISTIC (wall-clock time varies) +import time +current_time = time.time() +if current_time % 10 < 5: + spawn_enemy() + +# ✅ DETERMINISTIC (simulation ticks) +self.tick += 1 +if self.tick % 100 == 0: + spawn_enemy() +``` + +**Never use:** `Date.now()`, `Time.time()`, `performance.now()`, `clock_gettime()` + +#### 1.5 Multithreading/Async +```python +# ❌ NON-DETERMINISTIC (thread scheduling varies) +import threading + +def update_entity(entity): + entity.position += entity.velocity + +threads = [threading.Thread(target=update_entity, args=(e,)) for e in entities] +for t in threads: + t.start() +for t in threads: + t.join() + +# ✅ DETERMINISTIC (single-threaded or deterministic parallel) +for entity in entities: + entity.position += entity.velocity +``` + +**Rule:** Simulation must be single-threaded OR use deterministic parallel algorithms (map/reduce with associative operations). + +#### 1.6 Platform-Specific Behavior +```python +# ❌ NON-DETERMINISTIC (math functions differ by platform) +import math +angle = math.sin(1.5) # x86 vs ARM might differ in last bit + +# ✅ DETERMINISTIC (use consistent math library) +# Use fixed-point, or test on all platforms +# Or use deterministic math library (e.g., fixed-point trigonometry) +``` + +**Affected:** `sin`, `cos`, `sqrt` (hardware implementations vary) + +### 2. Replay Systems + +**What:** Record all inputs to a simulation, then replay them to reproduce exact behavior. + +**Architecture:** +``` +Normal Gameplay: + User Input → Simulation → Game State → Render + +Replay: + Recorded Inputs → Simulation → Game State → Render + (identical to original) +``` + +**Key Components:** + +#### 2.1 Input Recording +```python +class ReplayRecorder: + def __init__(self): + self.events = [] + + def record(self, tick, event_type, event_data): + """Record an input event""" + self.events.append({ + 'tick': tick, + 'type': event_type, + 'data': event_data + }) + + def save(self, filename): + """Save replay to file""" + import json + with open(filename, 'w') as f: + json.dump({ + 'version': 1, + 'seed': self.initial_seed, + 'events': self.events + }, f) + +# Usage +recorder = ReplayRecorder() +recorder.record(tick=0, event_type='spawn_deer', data={'x': 10, 'y': 20}) +recorder.record(tick=15, event_type='player_hunt', data={'target_id': 5}) +recorder.save('bug_reproduction.replay') +``` + +#### 2.2 Replay Playback +```python +class ReplayPlayer: + def __init__(self, replay_data): + self.events = replay_data['events'] + self.seed = replay_data['seed'] + self.event_index = 0 + + def get_events_for_tick(self, tick): + """Get all events that should occur this tick""" + events_this_tick = [] + while self.event_index < len(self.events): + event = self.events[self.event_index] + if event['tick'] == tick: + events_this_tick.append(event) + self.event_index += 1 + elif event['tick'] > tick: + break + else: + self.event_index += 1 + return events_this_tick + +# Usage +import json +with open('bug_reproduction.replay', 'r') as f: + replay_data = json.load(f) + +player = ReplayPlayer(replay_data) +sim = Simulation(seed=replay_data['seed']) + +for tick in range(1000): + events = player.get_events_for_tick(tick) + for event in events: + sim.apply_event(event) + sim.step() +``` + +#### 2.3 State Snapshots +```python +class SnapshotSystem: + def __init__(self): + self.snapshots = {} + + def save_snapshot(self, tick, simulation): + """Save complete simulation state""" + import copy + self.snapshots[tick] = copy.deepcopy(simulation.get_state()) + + def load_snapshot(self, tick): + """Restore simulation state""" + return self.snapshots.get(tick) + +# Usage - save every 100 ticks +snapshot_system = SnapshotSystem() +if sim.tick % 100 == 0: + snapshot_system.save_snapshot(sim.tick, sim) + +# Can jump to any saved tick for debugging +sim.set_state(snapshot_system.load_snapshot(500)) +``` + +**Real-World Example:** Rocket League saves input replay (not video) - only 100KB for 5-minute match. + +### 3. Debug Visualization + +**What:** Visual tools to inspect simulation state in real-time. Makes invisible bugs visible. + +**Key Visualizations:** + +#### 3.1 Debug Draw System +```python +class DebugDraw: + def __init__(self): + self.shapes = [] + + def draw_circle(self, x, y, radius, color, label=None): + """Draw debug circle""" + self.shapes.append({ + 'type': 'circle', + 'x': x, 'y': y, + 'radius': radius, + 'color': color, + 'label': label + }) + + def draw_line(self, x1, y1, x2, y2, color): + """Draw debug line""" + self.shapes.append({ + 'type': 'line', + 'x1': x1, 'y1': y1, + 'x2': x2, 'y2': y2, + 'color': color + }) + + def draw_text(self, x, y, text, color): + """Draw debug text""" + self.shapes.append({ + 'type': 'text', + 'x': x, 'y': y, + 'text': text, + 'color': color + }) + + def clear(self): + """Clear all debug shapes""" + self.shapes = [] + +# Usage in simulation +debug = DebugDraw() + +for deer in simulation.deer: + # Draw deer position + debug.draw_circle(deer.x, deer.y, radius=2, color='green') + + # Draw deer state + debug.draw_text(deer.x, deer.y + 3, f"E:{deer.energy:.0f}", color='white') + + # Draw deer seeking food + if deer.target_grass: + debug.draw_line(deer.x, deer.y, deer.target_grass.x, deer.target_grass.y, color='yellow') + +# Render debug shapes on screen +render_debug(debug.shapes) +``` + +#### 3.2 Population Graphs +```python +import matplotlib.pyplot as plt + +class PopulationGraph: + def __init__(self): + self.history = { + 'ticks': [], + 'deer': [], + 'wolves': [], + 'grass': [] + } + + def record(self, tick, deer_count, wolf_count, grass_amount): + """Record population snapshot""" + self.history['ticks'].append(tick) + self.history['deer'].append(deer_count) + self.history['wolves'].append(wolf_count) + self.history['grass'].append(grass_amount) + + def plot(self): + """Plot population over time""" + plt.figure(figsize=(12, 6)) + plt.plot(self.history['ticks'], self.history['deer'], label='Deer', color='green') + plt.plot(self.history['ticks'], self.history['wolves'], label='Wolves', color='red') + plt.plot(self.history['ticks'], self.history['grass'], label='Grass', color='brown', alpha=0.5) + plt.xlabel('Tick') + plt.ylabel('Population') + plt.title('Ecosystem Population Over Time') + plt.legend() + plt.grid(True) + plt.show() + +# Usage +graph = PopulationGraph() +for tick in range(1000): + simulation.step() + graph.record(tick, len(simulation.deer), len(simulation.wolves), simulation.grass) + +# Visualize +graph.plot() +``` + +**What to Look For:** +- Divergence: Two clients' graphs should be identical +- Oscillations: Natural or chaotic? +- Extinction events: Sudden drops to zero +- Runaway growth: Exponential explosion + +#### 3.3 State Comparison View +```python +class StateComparator: + def compare_states(self, state_a, state_b): + """Find differences between two simulation states""" + differences = [] + + # Compare entity counts + if len(state_a['deer']) != len(state_b['deer']): + differences.append({ + 'type': 'count_mismatch', + 'entity': 'deer', + 'count_a': len(state_a['deer']), + 'count_b': len(state_b['deer']) + }) + + # Compare entity states + for entity_id in state_a['deer']: + if entity_id not in state_b['deer']: + differences.append({ + 'type': 'missing_entity', + 'entity_id': entity_id, + 'present_in': 'A' + }) + continue + + deer_a = state_a['deer'][entity_id] + deer_b = state_b['deer'][entity_id] + + # Check position + if deer_a['x'] != deer_b['x'] or deer_a['y'] != deer_b['y']: + differences.append({ + 'type': 'position_mismatch', + 'entity_id': entity_id, + 'position_a': (deer_a['x'], deer_a['y']), + 'position_b': (deer_b['x'], deer_b['y']), + 'distance': math.sqrt((deer_a['x'] - deer_b['x'])**2 + + (deer_a['y'] - deer_b['y'])**2) + }) + + # Check energy + if deer_a['energy'] != deer_b['energy']: + differences.append({ + 'type': 'energy_mismatch', + 'entity_id': entity_id, + 'energy_a': deer_a['energy'], + 'energy_b': deer_b['energy'], + 'delta': deer_a['energy'] - deer_b['energy'] + }) + + return differences + +# Usage +comparator = StateComparator() +differences = comparator.compare_states(client1_state, client2_state) + +for diff in differences: + if diff['type'] == 'count_mismatch': + print(f"⚠️ {diff['entity']} count: {diff['count_a']} vs {diff['count_b']}") + elif diff['type'] == 'position_mismatch': + print(f"⚠️ Entity {diff['entity_id']} position off by {diff['distance']:.2f}") +``` + +### 4. Chaos Theory and Butterfly Effects + +**What:** Chaotic systems are sensitive to initial conditions. Tiny differences (0.0001) compound into massive divergence. + +**Example - Butterfly Effect:** +```python +def simulate_population(initial_deer): + deer = initial_deer + for tick in range(1000): + deer = deer * 1.1 - deer * deer / 500 # Logistic growth + return deer + +# Tiny difference in initial condition +result1 = simulate_population(100.0000) +result2 = simulate_population(100.0001) + +print(f"Result 1: {result1:.2f}") +print(f"Result 2: {result2:.2f}") +print(f"Difference: {abs(result1 - result2):.2f}") + +# Output: +# Result 1: 450.23 +# Result 2: 478.91 +# Difference: 28.68 (Tiny 0.0001 input → massive 28.68 output!) +``` + +**Why This Matters:** +- Floating point errors accumulate +- "Close enough" is NOT enough +- Must be bit-for-bit identical +- Small bugs cause massive desyncs over time + +**Feedback Loops Amplify Errors:** +``` +Tick 0: Deer = 100 vs 100.0001 (0.0001% error) +↓ +Tick 10: Deer = 110 vs 110.001 (0.001% error) - error grew 10x +↓ +Tick 100: Deer = 250 vs 252 (0.8% error) - error grew 800x +↓ +Tick 1000: Deer = 450 vs 478 (6% error) - error grew 6000x +``` + +**Identifying Chaotic Systems:** +- Oscillations grow over time (not dampen) +- Small parameter changes cause wildly different outcomes +- Sensitive to floating point precision +- Feedback loops (output becomes input) + +**Testing for Chaos:** +```python +def test_sensitivity(): + """Test if system is sensitive to initial conditions""" + base_result = simulate(initial_value=100.0) + + # Perturb initial condition by 0.01% + perturbed_result = simulate(initial_value=100.01) + + error = abs(base_result - perturbed_result) + error_percent = error / base_result * 100 + + if error_percent > 1.0: + print(f"⚠️ CHAOTIC SYSTEM: 0.01% input → {error_percent:.1f}% output") + print("Requires exact determinism, no tolerance for error") + else: + print(f"✓ Stable system: 0.01% input → {error_percent:.3f}% output") +``` + + +## Decision Frameworks + +### Framework 1: When Does Determinism Matter? + +**Question:** Do I need a fully deterministic simulation? + +**Decision Tree:** +``` +Q: Is this multiplayer with client-side prediction? +├─ YES → DETERMINISM REQUIRED +│ └─ Clients must stay in sync +│ +└─ NO → Q: Do you need replay functionality? + ├─ YES → DETERMINISM REQUIRED + │ └─ Replays must reproduce exactly + │ + └─ NO → Q: Are bugs reproducible? + ├─ NO → DETERMINISM HELPFUL + │ └─ Makes debugging much easier + │ + └─ YES → Determinism optional + └─ Can tolerate some randomness +``` + +**Examples:** + +| Game Type | Determinism | Why | +|-----------|-------------|-----| +| Multiplayer RTS (StarCraft) | REQUIRED | Lockstep networking, clients must sync | +| Fighting game (Street Fighter) | REQUIRED | Rollback netcode requires determinism | +| Single-player with replays (Rocket League) | REQUIRED | Replays must be exact | +| Competitive esports (any) | REQUIRED | Replays for analysis, bug reproduction | +| Single-player action (Assassin's Creed) | Optional | Random enemy spawns OK | +| Roguelike (Hades) | Optional | Randomness is feature | + +**Cost of Determinism:** +- Can't use platform math libraries (must use fixed-point or cross-platform lib) +- Can't use multithreading easily (need deterministic parallel algorithms) +- More testing required (verify on all platforms) +- Development time: +20-30% + +**Benefit of Determinism:** +- Multiplayer: Minimal bandwidth (send inputs, not state) +- Replays: 100KB instead of 1GB video +- Debugging: Reproducible bugs, not "random" failures +- Testing: Unit tests always pass/fail consistently + +### Framework 2: Replay System Complexity Level + +**Question:** How sophisticated should my replay system be? + +**Level 1: Minimal (Emergency Bug Reproduction)** +```python +# Just record inputs, no UI +class MinimalReplay: + def __init__(self): + self.inputs = [] + + def record(self, tick, action, data): + self.inputs.append((tick, action, data)) + + def save(self, filename): + import json + with open(filename, 'w') as f: + json.dump(self.inputs, f) +``` +**Time to implement:** 2-4 hours +**Use when:** Need to reproduce a bug ASAP + +**Level 2: Basic (Playback + Validation)** +```python +# Add playback, checksums, validation +class BasicReplay: + def record_with_checksum(self, tick, action, data, state_hash): + self.inputs.append({ + 'tick': tick, + 'action': action, + 'data': data, + 'checksum': state_hash # Verify replay matches + }) +``` +**Time to implement:** 1-2 days +**Use when:** Need replays for testing, regression testing + +**Level 3: Advanced (UI + Scrubbing + Debugging)** +```python +# Add UI, frame-by-frame, jump to tick +class AdvancedReplay: + def __init__(self): + self.snapshots = {} # Save state every N ticks + self.current_tick = 0 + + def jump_to_tick(self, target_tick): + # Find nearest snapshot before target + snapshot_tick = max([t for t in self.snapshots.keys() if t <= target_tick]) + self.restore_snapshot(snapshot_tick) + + # Simulate forward to exact tick + while self.current_tick < target_tick: + self.step() + + def step_forward(self): + """Advance one tick""" + self.current_tick += 1 + self.simulate_tick() + + def step_backward(self): + """Go back one tick (restore from snapshot + simulate)""" + self.jump_to_tick(self.current_tick - 1) +``` +**Time to implement:** 1-2 weeks +**Use when:** Complex debugging needs, esports replays, player-facing replays + +**Decision:** +- Critical bug, time-constrained → Level 1 (minimal) +- Ongoing development, need testing → Level 2 (basic) +- Shipped game, player replays → Level 3 (advanced) + +### Framework 3: Debug Visualization Strategy + +**Question:** What should I visualize for this bug? + +**Debugging Workflow:** +``` +1. Understand symptoms + ├─ Desync? → Visualize divergence points + ├─ Crash/instability? → Visualize state over time + ├─ Wrong behavior? → Visualize entity decisions + └─ Performance? → Visualize performance metrics + +2. Choose visualizations + ├─ Spatial bugs → Debug draw (positions, paths, ranges) + ├─ Temporal bugs → Graphs (populations, resources over time) + ├─ Logic bugs → State inspector (entity internal state) + └─ Comparison → Side-by-side diff (two clients) + +3. Iterate + └─ Add more detail to narrow down problem +``` + +**Visualization Types:** + +| Bug Type | Visualization | Example | +|----------|---------------|---------| +| Desync | State checksums, comparison table | "Tick 500: Client A has 50 deer, Client B has 52 deer" | +| Population collapse | Population graph | Graph shows deer plummet from 100 to 0 at tick 345 | +| Pathfinding | Debug draw lines | Show entity path, target destination, obstacles | +| Energy/resources | Bar charts over entities | Show energy bars above each deer | +| Collision | Debug circles | Show collision radius, overlap detection | +| Chaos/butterfly | Sensitivity graph | "0.01% input change → 15% output change" | + +**Implementation Priority:** +1. **State checksums** (detect problem exists) - 1 hour +2. **Population graphs** (see trends over time) - 2 hours +3. **Debug draw** (see spatial relationships) - 4 hours +4. **State inspector** (examine individual entities) - 4 hours +5. **Comparison diff** (find exact differences) - 8 hours + + +## Implementation Patterns + +### Pattern 1: Complete Deterministic Simulation (Production-Ready) + +```python +import hashlib +import json +import random +import copy + +class SeededRandom: + """Deterministic RNG - same seed → same sequence""" + def __init__(self, seed): + self.rng = random.Random(seed) + + def random(self): + return self.rng.random() + + def randint(self, a, b): + return self.rng.randint(a, b) + + def choice(self, sequence): + return self.rng.choice(sequence) + +class Entity: + """Base entity with deterministic ID""" + _next_id = 0 + + def __init__(self, x, y, energy): + self.id = Entity._next_id + Entity._next_id += 1 + self.x = x + self.y = y + self.energy = energy + self.alive = True + + def to_dict(self): + """Serialize for checksum""" + return { + 'id': self.id, + 'x': self.x, + 'y': self.y, + 'energy': self.energy, + 'alive': self.alive + } + +class Deer(Entity): + def __init__(self, x, y): + super().__init__(x, y, energy=100.0) + self.reproduction_cooldown = 0.0 + + def update(self, dt, rng, grass_amount): + """Deterministic update""" + # Deterministic energy consumption + self.energy -= 3.0 * dt + self.reproduction_cooldown = max(0, self.reproduction_cooldown - dt) + + # Deterministic eating + if grass_amount > 10: + eat_amount = min(20.0, grass_amount) + self.energy = min(100.0, self.energy + eat_amount * 0.5) + grass_consumed = eat_amount + else: + grass_consumed = 0 + + # Deterministic reproduction + can_reproduce = ( + self.energy > 80 and + self.reproduction_cooldown == 0 and + rng.random() < 0.1 # Seeded randomness + ) + + if can_reproduce: + self.energy -= 30.0 + self.reproduction_cooldown = 20.0 + return Deer(self.x + rng.random() * 4 - 2, self.y + rng.random() * 4 - 2), grass_consumed + + # Death from starvation + if self.energy <= 0: + self.alive = False + + return None, grass_consumed + +class DeterministicSimulation: + def __init__(self, seed): + """Initialize with seed for determinism""" + self.seed = seed + self.rng = SeededRandom(seed) + self.tick = 0 + self.grass = 1000.0 + self.deer = [] + self.wolves = [] + + # Deterministic initialization + for i in range(10): + self.deer.append(Deer(x=float(i * 10), y=0.0)) + + # State tracking + self.checksum_history = [] + + def step(self, dt=1.0): + """One simulation step - MUST be deterministic""" + self.tick += 1 + + # 1. Grass growth (deterministic) + self.grass += 5.0 * dt + self.grass = min(1000.0, self.grass) + + # 2. Update entities in deterministic order (sorted by ID) + self.deer.sort(key=lambda d: d.id) + + new_deer = [] + total_grass_consumed = 0.0 + + for deer in self.deer[:]: # Copy list to avoid mutation during iteration + if not deer.alive: + continue + + baby, grass_consumed = deer.update(dt, self.rng, self.grass) + total_grass_consumed += grass_consumed + + if baby: + new_deer.append(baby) + + # Apply grass consumption + self.grass -= total_grass_consumed + self.grass = max(0.0, self.grass) + + # Add babies + self.deer.extend(new_deer) + + # Remove dead deer + self.deer = [d for d in self.deer if d.alive] + + # 3. Compute checksum + checksum = self.compute_checksum() + self.checksum_history.append((self.tick, checksum)) + + return checksum + + def compute_checksum(self): + """Hash simulation state for desync detection""" + state = { + 'tick': self.tick, + 'grass': self.grass, + 'deer': sorted([d.to_dict() for d in self.deer], key=lambda x: x['id']) + } + state_json = json.dumps(state, sort_keys=True) + return hashlib.sha256(state_json.encode()).hexdigest() + + def get_state(self): + """Get complete simulation state (for snapshots)""" + return { + 'seed': self.seed, + 'tick': self.tick, + 'grass': self.grass, + 'deer': [copy.deepcopy(d.to_dict()) for d in self.deer], + 'rng_state': self.rng.rng.getstate() + } + + def set_state(self, state): + """Restore simulation state (for replay/debugging)""" + self.seed = state['seed'] + self.tick = state['tick'] + self.grass = state['grass'] + + # Restore entities + Entity._next_id = max([d['id'] for d in state['deer']] + [0]) + 1 + self.deer = [] + for deer_data in state['deer']: + deer = Deer(deer_data['x'], deer_data['y']) + deer.id = deer_data['id'] + deer.energy = deer_data['energy'] + deer.alive = deer_data['alive'] + self.deer.append(deer) + + # Restore RNG state + self.rng.rng.setstate(state['rng_state']) + +# TESTING DETERMINISM +def test_determinism(): + """Verify simulation is deterministic""" + print("Testing determinism...") + + # Run two simulations with same seed + sim1 = DeterministicSimulation(seed=42) + sim2 = DeterministicSimulation(seed=42) + + for i in range(100): + checksum1 = sim1.step() + checksum2 = sim2.step() + + if checksum1 != checksum2: + print(f"❌ DESYNC at tick {sim1.tick}!") + print(f" Sim1 checksum: {checksum1[:16]}") + print(f" Sim2 checksum: {checksum2[:16]}") + return False + + print(f"✅ Determinism verified - 100 ticks identical") + return True + +# Run test +test_determinism() +``` + +**Key Features:** +- ✅ Seeded RNG (same seed → same random values) +- ✅ Deterministic entity IDs +- ✅ Sorted iteration (consistent order) +- ✅ Fixed-point friendly (uses float but can be replaced) +- ✅ State checksums (detect desyncs) +- ✅ State save/load (snapshots for replay) +- ✅ Unit test (verify determinism) + +### Pattern 2: Replay System with Validation + +```python +class ReplaySystem: + def __init__(self): + self.events = [] + self.snapshots = {} + self.is_recording = True + self.is_replaying = False + self.replay_index = 0 + self.initial_state = None + + def start_recording(self, simulation): + """Start recording from current state""" + self.is_recording = True + self.is_replaying = False + self.events = [] + self.initial_state = simulation.get_state() + + def record_event(self, tick, event_type, event_data, state_checksum=None): + """Record an event with optional checksum""" + if not self.is_recording: + return + + self.events.append({ + 'tick': tick, + 'type': event_type, + 'data': event_data, + 'checksum': state_checksum + }) + + def save_snapshot(self, tick, simulation): + """Save state snapshot for fast seeking""" + self.snapshots[tick] = simulation.get_state() + + def start_replay(self): + """Start replaying recorded events""" + self.is_recording = False + self.is_replaying = True + self.replay_index = 0 + + def get_events_for_tick(self, tick): + """Get all events for this tick""" + events = [] + while self.replay_index < len(self.events): + event = self.events[self.replay_index] + if event['tick'] == tick: + events.append(event) + self.replay_index += 1 + elif event['tick'] > tick: + break + else: + self.replay_index += 1 + return events + + def validate_replay(self, simulation): + """Verify replay matches original""" + print("Validating replay...") + + # Restore initial state + simulation.set_state(self.initial_state) + + # Replay all events + for tick in range(max([e['tick'] for e in self.events]) + 1): + events = self.get_events_for_tick(tick) + for event in events: + # Apply event + if event['type'] == 'player_hunt': + simulation.hunt_deer(event['data']['deer_id']) + + simulation.step() + + # Validate checksum + current_checksum = simulation.compute_checksum() + expected_checksum = next((e['checksum'] for e in self.events if e['tick'] == tick and e['checksum']), None) + + if expected_checksum and current_checksum != expected_checksum: + print(f"❌ Replay diverged at tick {tick}") + print(f" Expected: {expected_checksum[:16]}") + print(f" Got: {current_checksum[:16]}") + return False + + print("✅ Replay validated successfully") + return True + + def save(self, filename): + """Save replay to file""" + with open(filename, 'w') as f: + json.dump({ + 'version': 1, + 'initial_state': self.initial_state, + 'events': self.events, + 'snapshots': self.snapshots + }, f, indent=2) + + def load(self, filename): + """Load replay from file""" + with open(filename, 'r') as f: + data = json.load(f) + self.initial_state = data['initial_state'] + self.events = data['events'] + self.snapshots = data.get('snapshots', {}) + +# USAGE EXAMPLE +replay = ReplaySystem() +sim = DeterministicSimulation(seed=12345) + +# Start recording +replay.start_recording(sim) + +# Simulate gameplay +for tick in range(100): + sim.step() + checksum = sim.compute_checksum() + replay.record_event(tick, 'tick', {}, state_checksum=checksum) + + # Save snapshot every 10 ticks + if tick % 10 == 0: + replay.save_snapshot(tick, sim) + +# Save replay +replay.save('test_replay.json') + +# Validate replay +replay.start_replay() +sim2 = DeterministicSimulation(seed=12345) +replay.validate_replay(sim2) +``` + +### Pattern 3: Debug Visualization Suite + +```python +class DebugVisualization: + def __init__(self): + self.enabled = True + self.draw_commands = [] + self.graphs = {} + + # Debug Draw + def draw_circle(self, x, y, radius, color, label=None): + """Draw debug circle""" + if not self.enabled: + return + self.draw_commands.append({ + 'type': 'circle', + 'x': x, 'y': y, + 'radius': radius, + 'color': color, + 'label': label + }) + + def draw_line(self, x1, y1, x2, y2, color, width=1): + """Draw debug line""" + if not self.enabled: + return + self.draw_commands.append({ + 'type': 'line', + 'start': (x1, y1), + 'end': (x2, y2), + 'color': color, + 'width': width + }) + + def draw_text(self, x, y, text, color='white', size=12): + """Draw debug text""" + if not self.enabled: + return + self.draw_commands.append({ + 'type': 'text', + 'x': x, 'y': y, + 'text': str(text), + 'color': color, + 'size': size + }) + + # Graphing + def init_graph(self, graph_name, series_names): + """Initialize a time-series graph""" + self.graphs[graph_name] = { + 'series': {name: [] for name in series_names}, + 'ticks': [] + } + + def record_values(self, graph_name, tick, **values): + """Record values for graph""" + if graph_name not in self.graphs: + return + + graph = self.graphs[graph_name] + graph['ticks'].append(tick) + + for series_name, value in values.items(): + if series_name in graph['series']: + graph['series'][series_name].append(value) + + def plot_graph(self, graph_name): + """Plot graph using matplotlib""" + import matplotlib.pyplot as plt + + if graph_name not in self.graphs: + return + + graph = self.graphs[graph_name] + + plt.figure(figsize=(12, 6)) + for series_name, values in graph['series'].items(): + plt.plot(graph['ticks'], values, label=series_name) + + plt.xlabel('Tick') + plt.ylabel('Value') + plt.title(f'{graph_name} Over Time') + plt.legend() + plt.grid(True) + plt.show() + + # State Inspection + def inspect_entity(self, entity, x, y): + """Show entity state on screen""" + self.draw_circle(entity.x, entity.y, radius=2, color='yellow') + + state_text = f"ID: {entity.id}\n" + state_text += f"Energy: {entity.energy:.1f}\n" + state_text += f"Pos: ({entity.x:.1f}, {entity.y:.1f})" + + self.draw_text(x, y, state_text, color='white', size=10) + + # Comparison + def compare_states(self, state_a, state_b, label_a="Client A", label_b="Client B"): + """Compare two simulation states""" + print(f"\n=== State Comparison: {label_a} vs {label_b} ===") + + # Compare deer counts + deer_a = len(state_a['deer']) + deer_b = len(state_b['deer']) + + if deer_a != deer_b: + print(f"❌ Deer count: {deer_a} vs {deer_b} (diff: {abs(deer_a - deer_b)})") + else: + print(f"✅ Deer count: {deer_a} (identical)") + + # Compare grass + grass_a = state_a['grass'] + grass_b = state_b['grass'] + grass_diff = abs(grass_a - grass_b) + + if grass_diff > 0.01: + print(f"❌ Grass: {grass_a:.2f} vs {grass_b:.2f} (diff: {grass_diff:.2f})") + else: + print(f"✅ Grass: {grass_a:.2f} (identical)") + + # Compare individual deer + deer_ids_a = set(d['id'] for d in state_a['deer']) + deer_ids_b = set(d['id'] for d in state_b['deer']) + + missing_in_b = deer_ids_a - deer_ids_b + missing_in_a = deer_ids_b - deer_ids_a + + if missing_in_b: + print(f"❌ Deer only in {label_a}: {missing_in_b}") + if missing_in_a: + print(f"❌ Deer only in {label_b}: {missing_in_a}") + + # Compare common deer + common_ids = deer_ids_a & deer_ids_b + for deer_id in common_ids: + deer_a = next(d for d in state_a['deer'] if d['id'] == deer_id) + deer_b = next(d for d in state_b['deer'] if d['id'] == deer_id) + + energy_diff = abs(deer_a['energy'] - deer_b['energy']) + if energy_diff > 0.01: + print(f"❌ Deer {deer_id} energy: {deer_a['energy']:.2f} vs {deer_b['energy']:.2f}") + + def clear(self): + """Clear debug draw commands""" + self.draw_commands = [] + +# USAGE IN SIMULATION +debug = DebugVisualization() + +# Initialize graphs +debug.init_graph('population', ['deer', 'wolves', 'grass']) + +# During simulation +for tick in range(1000): + sim.step() + + # Record for graph + debug.record_values('population', tick, + deer=len(sim.deer), + wolves=len(sim.wolves), + grass=sim.grass) + + # Debug draw + for deer in sim.deer: + debug.draw_circle(deer.x, deer.y, radius=2, color='green') + debug.draw_text(deer.x, deer.y + 3, f"E:{deer.energy:.0f}", color='white') + + # Clear for next frame + debug.clear() + +# Plot graph +debug.plot_graph('population') + +# Compare states +state1 = sim1.get_state() +state2 = sim2.get_state() +debug.compare_states(state1, state2, "Client 1", "Client 2") +``` + +### Pattern 4: Assertion Framework (Fail Fast) + +```python +class SimulationAssertions: + """Runtime checks to catch bugs immediately""" + + @staticmethod + def assert_positive(value, name): + """Assert value is positive""" + assert value >= 0, f"{name} must be >= 0, got {value}" + + @staticmethod + def assert_in_range(value, min_val, max_val, name): + """Assert value is in range""" + assert min_val <= value <= max_val, f"{name} must be in [{min_val}, {max_val}], got {value}" + + @staticmethod + def assert_energy_valid(entity): + """Assert entity energy is valid""" + assert 0 <= entity.energy <= 100, f"Entity {entity.id} energy invalid: {entity.energy}" + + @staticmethod + def assert_population_bounded(simulation, max_deer, max_wolves): + """Assert populations don't explode""" + deer_count = len(simulation.deer) + wolf_count = len(simulation.wolves) + + assert deer_count <= max_deer, f"Deer explosion: {deer_count} > {max_deer}" + assert wolf_count <= max_wolves, f"Wolf explosion: {wolf_count} > {max_wolves}" + + @staticmethod + def assert_no_duplicates(entities): + """Assert no duplicate entity IDs""" + ids = [e.id for e in entities] + assert len(ids) == len(set(ids)), f"Duplicate entity IDs found: {ids}" + + @staticmethod + def assert_checksum_matches(simulation, expected_checksum): + """Assert state checksum matches expected""" + actual = simulation.compute_checksum() + assert actual == expected_checksum, f"Checksum mismatch!\n Expected: {expected_checksum[:16]}\n Got: {actual[:16]}" + +# USE IN SIMULATION +class AssertingSimulation(DeterministicSimulation): + def step(self, dt=1.0): + """Step with assertions""" + + # Pre-conditions + SimulationAssertions.assert_positive(self.grass, "grass") + SimulationAssertions.assert_no_duplicates(self.deer) + + # Run simulation + result = super().step(dt) + + # Post-conditions + SimulationAssertions.assert_population_bounded(self, max_deer=1000, max_wolves=200) + + for deer in self.deer: + SimulationAssertions.assert_energy_valid(deer) + + return result + +# TESTING WITH ASSERTIONS +sim = AssertingSimulation(seed=42) +for tick in range(1000): + sim.step() # Fails immediately if any assertion violated +``` + +### Pattern 5: Property-Based Testing + +```python +# Requires: pip install hypothesis +from hypothesis import given, strategies as st +import hypothesis + +class PropertyTests: + """Property-based tests for simulation invariants""" + + @staticmethod + @given(seed=st.integers(min_value=0, max_value=1000000)) + def test_determinism_property(seed): + """Property: Same seed always produces same result""" + sim1 = DeterministicSimulation(seed=seed) + sim2 = DeterministicSimulation(seed=seed) + + for _ in range(10): + checksum1 = sim1.step() + checksum2 = sim2.step() + assert checksum1 == checksum2, "Determinism violated" + + @staticmethod + @given(seed=st.integers(min_value=0, max_value=1000000)) + def test_energy_conservation_property(seed): + """Property: Total energy never increases without input""" + sim = DeterministicSimulation(seed=seed) + + for _ in range(100): + energy_before = sum(d.energy for d in sim.deer) + sim.grass + sim.step() + energy_after = sum(d.energy for d in sim.deer) + sim.grass + + # Energy can only decrease (metabolism) or stay same (eating just moves it) + assert energy_after <= energy_before + 10, "Energy created from nothing!" + + @staticmethod + @given(seed=st.integers(min_value=0, max_value=1000000)) + def test_population_bounded_property(seed): + """Property: Populations stay within reasonable bounds""" + sim = DeterministicSimulation(seed=seed) + + for _ in range(100): + sim.step() + assert len(sim.deer) <= 1000, "Deer population explosion" + assert len(sim.deer) >= 0, "Negative deer population" + + @staticmethod + def test_replay_reproducibility_property(): + """Property: Replay always produces identical result""" + sim = DeterministicSimulation(seed=123) + replay = ReplaySystem() + replay.start_recording(sim) + + # Run simulation + for tick in range(50): + sim.step() + replay.record_event(tick, 'tick', {}, state_checksum=sim.compute_checksum()) + + # Replay should be identical + assert replay.validate_replay(DeterministicSimulation(seed=123)) + +# RUN PROPERTY TESTS +if __name__ == '__main__': + PropertyTests.test_determinism_property() + PropertyTests.test_energy_conservation_property() + PropertyTests.test_population_bounded_property() + PropertyTests.test_replay_reproducibility_property() + print("✅ All property tests passed") +``` + + +## Common Pitfalls + +### Pitfall 1: Unseeded Random Number Generation + +**The Mistake:** +```python +# ❌ Uses system entropy - different on each client +import random +if random.random() < 0.5: + spawn_deer() +``` + +**Why This Fails:** +- Each client generates different random numbers +- Multiplayer: Instant desync +- Testing: Bug not reproducible (different each run) + +**Real Example:** +Multiplayer ecosystem - Client A rolls 0.48 (spawns deer), Client B rolls 0.52 (doesn't spawn). Now Client A has 51 deer, Client B has 50. Error compounds over time. + +**The Fix:** +```python +# ✅ Seeded RNG - same seed produces same sequence +class SeededRandom: + def __init__(self, seed): + self.rng = random.Random(seed) + + def random(self): + return self.rng.random() + +# All clients use same seed +rng = SeededRandom(seed=12345) +if rng.random() < 0.5: + spawn_deer() +``` + +### Pitfall 2: Map/Dictionary Iteration Order + +**The Mistake:** +```python +# ❌ Iteration order is undefined (Python < 3.7, JavaScript, C++) +entities = {'deer_1': deer1, 'wolf_2': wolf2, 'deer_3': deer3} +for entity_id, entity in entities.items(): + entity.update() # ORDER VARIES! +``` + +**Why This Fails:** +- Iteration order affects which entity updates first +- If entity1 eats all food before entity2 can, order matters +- Multiplayer: Clients iterate in different orders → desync + +**Real Example:** +Deer A and Deer B both targeting same grass patch. On Client 1, Deer A updates first (eats grass). On Client 2, Deer B updates first (eats grass). Now Deer A and Deer B have different energy on each client. + +**The Fix:** +```python +# ✅ Sort keys before iterating +for entity_id in sorted(entities.keys()): + entities[entity_id].update() +``` + +### Pitfall 3: Floating Point Accumulation + +**The Mistake:** +```python +# ❌ Accumulating floating point creates divergence +position = 0.0 +for _ in range(10000): + position += 0.1 # Rounding errors compound! +``` + +**Why This Fails:** +- `0.1` cannot be represented exactly in binary floating point +- Error: ~0.0000000000000001 per addition +- After 10,000 additions: ~0.001 total error +- Multiplayer: Different CPU architectures round differently + +**Real Example:** +After 1 hour of gameplay (360,000 frames at 60 FPS): +- Client A (x86): deer position = 1234.567 +- Client B (ARM): deer position = 1234.571 +- 0.004 difference → deer on different tiles → different grass eaten → DESYNC + +**The Fix:** +```python +# ✅ Use fixed-point or integer math for critical values +position_int = 0 # Store as integer (units = 0.001) +for _ in range(10000): + position_int += 100 # 0.1 * 1000 = 100 +position = position_int / 1000.0 # Convert to float for display +``` + +Or use integer positions: +```python +# Position in millimeters instead of meters +x_mm = 5000 # 5 meters +y_mm = 3000 # 3 meters + +# No floating point errors +x_mm += velocity_mm_per_tick +``` + +### Pitfall 4: Using Wall-Clock Time + +**The Mistake:** +```python +# ❌ Uses real-world time - varies between clients +import time +current_time = time.time() +if current_time % 60 < 30: + spawn_enemy() +``` + +**Why This Fails:** +- Clients start at different real-world times +- Network latency causes time skew +- Frame rate differences mean different number of checks + +**Real Example:** +- Client A checks at 14:30:25 → time % 60 = 25 → spawns enemy +- Client B checks at 14:30:26 (1 second lag) → time % 60 = 26 → spawns enemy +- Now clients have enemies at different positions + +**The Fix:** +```python +# ✅ Use simulation ticks (deterministic) +self.tick += 1 +if self.tick % 600 == 0: # Every 600 ticks + spawn_enemy() +``` + +### Pitfall 5: No Replay System (Can't Reproduce Bugs) + +**The Mistake:** +```python +# ❌ No recording - bug happens once, can't debug +def run_simulation(): + for tick in range(10000): + simulate_tick() + # Bug happened at tick 7345, but you don't know that + # And you can't reproduce it +``` + +**Why This Fails:** +- Can't reproduce bug to debug +- Can't verify fix actually works +- Can't create regression tests +- Can't analyze what happened + +**Real Example:** +"Deer population goes to zero after 30 minutes" - without replay, you don't know: +- Which tick did first deer die? +- What was energy level before death? +- Was there grass available? +- What were other deer doing? + +**The Fix:** +```python +# ✅ Record inputs for replay +replay = ReplaySystem() +replay.start_recording(sim) + +for tick in range(10000): + checksum = simulate_tick() + replay.record_event(tick, 'tick', {}, checksum=checksum) + +# Save when bug occurs +if bug_detected(): + replay.save('bug_7345.replay') + # Now you can replay and debug at exactly tick 7345 +``` + +### Pitfall 6: No Debug Visualization (Debugging Blind) + +**The Mistake:** +```python +# ❌ No visualization - can't see what's happening +simulation.step() +# Is deer moving toward grass or away? +# Is wolf actually chasing deer? +# Why did population spike? +# NO IDEA - just looking at numbers +``` + +**Why This Fails:** +- Can't see spatial relationships +- Can't see trends over time +- Can't spot anomalies visually +- Waste hours guessing + +**Real Example:** +"Deer population oscillates weirdly" - without graph, you don't see: +- Oscillation period getting shorter (approaching chaos) +- Oscillation amplitude growing (instability) +- Sudden spike at tick 500 (bug trigger) + +**The Fix:** +```python +# ✅ Visualize everything +debug = DebugVisualization() + +# Draw entities +for deer in simulation.deer: + debug.draw_circle(deer.x, deer.y, radius=2, color='green') + debug.draw_text(deer.x, deer.y + 3, f"E:{deer.energy:.0f}") + +# Graph populations +debug.record_values('population', tick, deer=len(simulation.deer)) +debug.plot_graph('population') # See trends immediately +``` + +### Pitfall 7: No Assertions (Silent Failures) + +**The Mistake:** +```python +# ❌ Bug happens, simulation keeps running with corrupted state +deer.energy = -50 # INVALID but no error +simulation.deer.append(existing_deer) # DUPLICATE but no error +# Corrupted state → weird behavior 1000 ticks later → impossible to debug +``` + +**Why This Fails:** +- Bug symptoms appear far from root cause +- Corrupted state compounds over time +- Hard to trace back to original error + +**Real Example:** +Tick 100: Deer energy goes negative (bug) +Tick 500: Negative energy deer reproduces (shouldn't happen) +Tick 1000: Population explosion from immortal deer (visible symptom) +Without assertion, you debug tick 1000, never find root cause at tick 100. + +**The Fix:** +```python +# ✅ Fail fast with assertions +deer.energy -= consumption +assert deer.energy >= 0, f"Deer {deer.id} energy negative: {deer.energy}" +# Fails immediately at tick 100, shows exact problem +``` + +### Pitfall 8: Testing Only Happy Path (Missing Edge Cases) + +**The Mistake:** +```python +# ❌ Only test normal conditions +def test_ecosystem(): + sim = Simulation() + sim.step() # With 10 deer, 2 wolves + assert len(sim.deer) > 0 # Passes! + +# But never test: +# - What if 0 deer? +# - What if 1000 deer? +# - What if 0 grass? +# - What if deer.energy = 0.0001? +``` + +**Why This Fails:** +- Edge cases trigger bugs +- Integer overflow at extreme values +- Division by zero when populations hit zero +- Floating point precision at very small values + +**The Fix:** +```python +# ✅ Test edge cases +def test_edge_cases(): + # Zero population + sim = Simulation() + sim.deer = [] + sim.step() # Should not crash + + # Huge population + sim.deer = [Deer() for _ in range(10000)] + sim.step() # Should not explode + + # Extreme values + deer = Deer() + deer.energy = 0.0001 # Nearly dead + deer.update(dt=1.0) # Should handle gracefully +``` + +### Pitfall 9: Not Understanding Butterfly Effects + +**The Mistake:** +```python +# ❌ Thinks "close enough" is fine +if abs(deer_count_a - deer_count_b) < 5: + print("Practically the same!") # WRONG! + +# Small difference compounds exponentially +``` + +**Why This Fails:** +- Chaotic systems amplify small differences +- 0.1% error at tick 0 → 10% error at tick 1000 +- Feedback loops compound errors +- "Close" is not deterministic + +**Real Example:** +Tick 0: 100 deer vs 100.01 deer (0.01% difference) +Tick 100: 150 deer vs 151 deer (0.7% difference - grew 70x) +Tick 500: 300 deer vs 325 deer (8% difference - grew 800x) +Tick 1000: 450 deer vs 520 deer (15% difference - grew 1500x) + +**The Fix:** +```python +# ✅ Require exact match +assert deer_count_a == deer_count_b, "Must be EXACTLY equal" +# Or checksums +assert checksum_a == checksum_b, "States must be bit-for-bit identical" +``` + + +## Real-World Examples + +### Example 1: Rocket League - Replay System + +**Architecture:** Deterministic physics + input recording + +**How It Works:** +- Game records: Player inputs, initial ball/car positions, RNG seed +- File size: ~100 KB for 5-minute match (tiny!) +- Replay: Re-runs physics simulation with recorded inputs +- Result: Bit-for-bit identical to original match + +**Technical Details:** +```python +# Conceptual Rocket League replay +class RocketLeagueReplay: + def __init__(self): + self.initial_state = { + 'ball_pos': (0, 0, 100), + 'ball_vel': (0, 0, 0), + 'cars': [ + {'pos': (-2000, 0, 17), 'vel': (0, 0, 0)}, + {'pos': (2000, 0, 17), 'vel': (0, 0, 0)} + ], + 'rng_seed': 42 + } + self.inputs = [] + + def record_input(self, tick, player_id, throttle, steer, boost): + """Record player input""" + self.inputs.append({ + 'tick': tick, + 'player': player_id, + 'throttle': throttle, + 'steer': steer, + 'boost': boost + }) + + def replay(self): + """Replay match from inputs""" + physics = DeterministicPhysics(seed=self.initial_state['rng_seed']) + physics.set_initial_state(self.initial_state) + + for tick in range(18000): # 5 min @ 60fps + # Apply player inputs + for input_event in self.get_inputs_for_tick(tick): + physics.apply_input(input_event) + + # Step physics (deterministic) + physics.step(dt=1/60.0) + + return physics.get_state() +``` + +**Key Lessons:** +- Deterministic physics engine required +- Replays compress 1000x (100 KB vs 100 MB video) +- Can analyze pro plays in extreme detail +- Can debug "phantom hits" by inspecting exact frame + +**Determinism Requirements:** +- Fixed timestep (1/60 second) +- Seeded RNG for ball bounce variance +- Fixed-point math for position/velocity +- Sorted player update order + +### Example 2: StarCraft II - Deterministic RTS + +**Challenge:** 300+ units, complex AI, must stay in sync across clients. + +**Architecture:** Lockstep networking + deterministic simulation + +**How It Works:** +``` +Tick 0: + Client A: Send "Move unit 5 to (100, 200)" + Client B: Send "Attack unit 12 with unit 8" + +Tick 1: + Both clients receive both commands + Both clients execute in SAME ORDER (sorted by player ID) + Both clients simulate identically + +Result: Clients stay in sync, only send tiny command messages +``` + +**Determinism Techniques:** +```python +# SC2-style deterministic update +class SC2Simulation: + def __init__(self, seed): + self.rng = SeededRandom(seed) + self.units = {} + self.tick = 0 + + def step(self, commands): + """ + Execute one tick + commands: List of player commands (already sorted) + """ + self.tick += 1 + + # Execute commands in deterministic order + for command in sorted(commands, key=lambda c: (c.player_id, c.unit_id)): + self.execute_command(command) + + # Update all units in deterministic order (sorted by unit ID) + for unit_id in sorted(self.units.keys()): + self.units[unit_id].update(self.rng) + + # Collision detection (deterministic order) + self.detect_collisions() + + return self.compute_checksum() + + def execute_command(self, command): + """Execute player command deterministically""" + unit = self.units[command.unit_id] + + if command.type == 'move': + unit.target = command.position + elif command.type == 'attack': + unit.target_unit = command.target_unit_id + + def compute_checksum(self): + """Hash game state to detect desyncs""" + state = { + 'tick': self.tick, + 'units': sorted([u.to_dict() for u in self.units.values()], + key=lambda u: u['id']) + } + import hashlib, json + return hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest() +``` + +**Desync Detection:** +```python +# Clients send checksums periodically +if self.tick % 60 == 0: # Every 1 second + checksum = self.compute_checksum() + send_to_server({'type': 'checksum', 'tick': self.tick, 'checksum': checksum}) + +# Server compares +if client_a_checksum != client_b_checksum: + print(f"DESYNC DETECTED at tick {tick}!") + # Pause game, log replay, investigate +``` + +**Key Lessons:** +- Sort EVERYTHING (commands, unit updates, collisions) +- Seeded RNG for random events (critical hits) +- Checksum validation every second +- Pause game immediately on desync (don't let it compound) + +### Example 3: Age of Empires II - Floating Point Determinism + +**Problem:** Different CPUs (x86 vs ARM) computed math differently → desyncs. + +**Solution:** Fixed-point math for all positions and calculations. + +**Before (Floating Point):** +```cpp +// ❌ Non-deterministic across platforms +float unit_x = 100.0f; +unit_x += velocity * deltaTime; // Different rounding on x86 vs ARM! +``` + +**After (Fixed-Point):** +```cpp +// ✅ Deterministic - integers are always same +typedef int32_t fixed_t; // 16.16 fixed-point +#define FIXED_SHIFT 16 +#define FIXED_ONE (1 << FIXED_SHIFT) + +fixed_t unit_x = 100 * FIXED_ONE; // 100.0 in fixed-point +unit_x += velocity * deltaTime / FIXED_ONE; // Integer math, always identical +``` + +**Python Example:** +```python +class FixedPoint: + """16.16 fixed-point number (16 bits integer, 16 bits fraction)""" + SHIFT = 16 + ONE = 1 << SHIFT + + def __init__(self, value=0): + if isinstance(value, float): + self.raw = int(value * self.ONE) + else: + self.raw = value + + def to_float(self): + return self.raw / self.ONE + + def __add__(self, other): + result = FixedPoint() + result.raw = self.raw + other.raw + return result + + def __mul__(self, other): + result = FixedPoint() + # Multiply then shift to avoid overflow + result.raw = (self.raw * other.raw) >> self.SHIFT + return result + +# Usage +x = FixedPoint(100.5) # 100.5 in fixed-point +y = FixedPoint(2.25) +z = x + y # 102.75 +print(z.to_float()) # 102.75 - deterministic on all platforms! +``` + +**Key Lessons:** +- Fixed-point eliminates platform differences +- Slight precision loss acceptable for determinism +- Use for: positions, velocities, health values +- Keep floats for: rendering, UI, non-critical values + +### Example 4: Factorio - Deterministic Factory Simulation + +**Challenge:** 1000s of entities (assemblers, belts, inserters) must stay in sync. + +**Architecture:** Entity component system + deterministic update order + +**Determinism Strategy:** +```python +class FactorioSimulation: + def __init__(self, seed): + self.rng = SeededRandom(seed) + self.entities = {} # entity_id → entity + self.tick = 0 + + def step(self): + """One tick - update all entities deterministically""" + self.tick += 1 + + # Update in deterministic order (sorted by entity ID) + for entity_id in sorted(self.entities.keys()): + entity = self.entities[entity_id] + entity.update(self) + + # Process entity interactions (deterministic order) + self.process_item_transfers() + self.process_crafting() + self.process_power_network() + + def process_item_transfers(self): + """Move items between entities deterministically""" + # Get all transfer requests + transfers = [] + for entity_id in sorted(self.entities.keys()): + entity = self.entities[entity_id] + if hasattr(entity, 'get_transfer_requests'): + transfers.extend(entity.get_transfer_requests()) + + # Sort transfers deterministically + transfers.sort(key=lambda t: (t.source_id, t.dest_id, t.item_type)) + + # Execute transfers in order + for transfer in transfers: + self.execute_transfer(transfer) +``` + +**Item on Belt Determinism:** +```python +class TransportBelt: + def __init__(self, id): + self.id = id + self.items = [] # List of items on belt + + def update(self, simulation): + """Move items along belt""" + # Sort items by position for deterministic processing + self.items.sort(key=lambda item: item.position) + + # Move each item + for item in self.items: + item.position += 0.03125 # Fixed speed (1/32 per tick) + + # If at end, try to transfer + if item.position >= 1.0: + self.try_transfer_item(item, simulation) +``` + +**Key Lessons:** +- Sort entities before update +- Sort items on belts before processing +- Fixed speed (no floating point accumulation) +- Deterministic collision resolution (sort by ID) + +### Example 5: Overwatch - Replay Debugging + +**Use Case:** Debug "favor the shooter" issues where client sees hit but server disagrees. + +**Architecture:** Server records authoritative replay + client replays + +**Debugging Process:** +```python +class OverwatchDebugger: + def debug_hit_registration(self, bug_report): + """Debug why shot didn't register""" + + # 1. Load server replay + server_replay = load_replay(bug_report.match_id) + server_replay.jump_to_tick(bug_report.tick) + + # 2. Load client replay (if available) + client_replay = load_replay(bug_report.client_replay_id) + client_replay.jump_to_tick(bug_report.tick) + + # 3. Compare states + server_state = server_replay.get_state() + client_state = client_replay.get_state() + + # 4. Visualize differences + debug = DebugVisualization() + + # Draw server's view of player positions + debug.draw_circle(server_state.shooter.x, server_state.shooter.y, + radius=1, color='blue', label='Server') + debug.draw_circle(server_state.target.x, server_state.target.y, + radius=1, color='red', label='Server Target') + + # Draw client's view + debug.draw_circle(client_state.shooter.x, client_state.shooter.y, + radius=1, color='cyan', label='Client') + debug.draw_circle(client_state.target.x, client_state.target.y, + radius=1, color='orange', label='Client Target') + + # Draw raycast + debug.draw_line(server_state.shooter.x, server_state.shooter.y, + server_state.raycast_hit.x, server_state.raycast_hit.y, + color='yellow') + + # 5. Identify discrepancy + distance = math.sqrt((server_state.target.x - client_state.target.x)**2 + + (server_state.target.y - client_state.target.y)**2) + + print(f"Target position difference: {distance:.3f} meters") + print(f"Network latency: {bug_report.ping}ms") + print(f"Conclusion: Client prediction error due to {bug_report.ping}ms lag") +``` + +**Key Lessons:** +- Both client and server record replays +- Can replay side-by-side to compare +- Visualize discrepancies (where did each think target was?) +- Identify root cause (lag, prediction error, actual bug) + + +## Cross-References + +### Use This Skill WITH: +- **ecosystem-simulation**: Ecosystem desyncs, population divergence +- **physics-simulation-patterns**: Physics determinism, floating point errors +- **ai-and-agent-simulation**: Deterministic AI, sorted agent updates +- **crowd-simulation**: Deterministic crowd movement, collision resolution +- **economic-simulation-patterns**: Deterministic economy calculations + +### Use This Skill BEFORE: +- **multiplayer-implementation**: Must ensure determinism first +- **replay-systems**: Determinism required for replays +- **competitive-esports**: Tournament integrity requires bug-free determinism + +### Broader Context: +- **systematic-debugging** (superpowers): General debugging methodology +- **test-driven-development** (superpowers): Write tests for determinism +- **root-cause-tracing** (superpowers): Trace desync to root cause + + +## Testing Checklist + +### Determinism Validation +- [ ] Run simulation twice with same seed → identical checksums +- [ ] Test on different platforms (x86, ARM, Windows, Linux, Mac) +- [ ] Verify RNG is seeded per simulation (not global) +- [ ] Check all dict/map iteration is sorted +- [ ] Validate no `Date.now()` or wall-clock time usage +- [ ] Test multithreading is deterministic (or disabled) +- [ ] Run 10,000 ticks without divergence + +### Replay System +- [ ] Record inputs and initial state +- [ ] Replay produces identical result to original +- [ ] Checksum validation detects any divergence +- [ ] Can save and load replay files +- [ ] Can jump to any tick using snapshots +- [ ] Replay file size reasonable (< 1MB for 10 min gameplay) + +### Debug Visualization +- [ ] Debug draw shows entity positions +- [ ] Population graphs show trends over time +- [ ] Can compare two simulation states side-by-side +- [ ] State inspector shows entity internal values +- [ ] Frame-by-frame stepping works +- [ ] Visual diff highlights discrepancies + +### Chaos/Butterfly Effect +- [ ] Test sensitivity to initial conditions (0.01% perturbation) +- [ ] Identify feedback loops that amplify errors +- [ ] Verify "close enough" is not used (must be exact) +- [ ] Test stability under parameter changes +- [ ] Check oscillations dampen (not grow exponentially) + +### Edge Cases +- [ ] Zero population (ecosystem empty) +- [ ] Extreme populations (10,000+ entities) +- [ ] Zero resources (grass depleted) +- [ ] Boundary conditions (map edges, value limits) +- [ ] Rapid events (1000 entities spawned at once) +- [ ] Long runtime (10+ hours without issues) + +### Property-Based Testing +- [ ] Determinism property (same seed → same result) +- [ ] Energy conservation (total energy <= initial) +- [ ] Population bounds (never negative, never explode) +- [ ] Replay reproducibility (replay always matches) +- [ ] State invariants (no duplicate IDs, valid ranges) + +### Multiplayer Sync +- [ ] Clients exchange checksums periodically +- [ ] Desync detected within 1 second +- [ ] Pause game on desync detection +- [ ] Log replay for debugging +- [ ] Can identify which subsystem diverged + + +## Summary + +Debugging simulation chaos requires **determinism**, **replay systems**, **debug visualization**, and **systematic methodology**. + +**Core Principles:** +1. **Determinism is non-negotiable** - Same inputs → Same outputs, always +2. **Replay everything** - Can't debug what you can't reproduce +3. **Visualize everything** - Make invisible bugs visible +4. **Test systematically** - Property-based tests, edge cases, chaos tests +5. **Fail fast** - Assertions catch bugs at root cause, not symptoms +6. **Understand chaos** - Butterfly effects mean 0.0001% error → 10% divergence + +**Most Common Failures:** +- ❌ Unseeded RNG → desync +- ❌ Unordered map iteration → desync +- ❌ Floating point accumulation → desync +- ❌ Wall-clock time → desync +- ❌ No replay system → can't reproduce bugs +- ❌ No visualization → debugging blind +- ❌ "Close enough" tolerance → chaotic systems compound errors + +**Success Pattern:** +```python +# 1. Deterministic simulation +sim = DeterministicSimulation(seed=42) + +# 2. Record replay +replay = ReplaySystem() +replay.start_recording(sim) + +# 3. Add assertions +assert sim.tick >= 0 +assert len(sim.deer) <= MAX_DEER + +# 4. Visualize +debug = DebugVisualization() +debug.plot_graph('population') + +# 5. Test determinism +assert sim1.checksum() == sim2.checksum() +``` + +Master these patterns, avoid the pitfalls, and your simulations will be deterministic, debuggable, and rock-solid reliable. diff --git a/skills/using-simulation-tactics/economic-simulation-patterns.md b/skills/using-simulation-tactics/economic-simulation-patterns.md new file mode 100644 index 0000000..ba79d32 --- /dev/null +++ b/skills/using-simulation-tactics/economic-simulation-patterns.md @@ -0,0 +1,2152 @@ + +# Economic Simulation Patterns + +## Description +Master supply/demand dynamics, market simulation, production chains, and economic balance for game economies. Apply faucet/sink analysis, exploit validation, and price stabilization to create stable, engaging economies that resist inflation, arbitrage loops, and market manipulation over months of gameplay. + +## When to Use This Skill +Use this skill when implementing or debugging: +- Trading games with dynamic markets (space sims, MMO auction houses) +- Resource-based economies (crafting, production chains) +- Player-to-player marketplaces +- NPC vendors and dynamic pricing +- Currency systems and money supply management +- Multi-month economic balance and stability + +Do NOT use this skill for: +- Simple fixed-price vendors (no supply/demand) +- Single-player games without trading +- Abstract resources without markets (XP, skill points) +- Cosmetic-only economies (no gameplay impact) + + +## Quick Start (Time-Constrained Implementation) + +If you need a working economy quickly (< 4 hours), follow this priority order: + +**CRITICAL (Never Skip)**: +1. **Faucets < Sinks**: Money entering system must be less than money leaving +2. **Validate chains**: Production chains must be net-negative vs NPCs (no infinite money) +3. **Price bounds**: Set min/max prices (prevent 0 or infinity) +4. **NPC bid-ask spread**: NPCs buy at 50-70% of sell price + +**IMPORTANT (Strongly Recommended)**: +5. Transaction fees (1-5% per trade removes money from system) +6. Consumables (ammo, fuel, food - require constant spending) +7. Price dampening (1.01x adjustments, not 1.1x) +8. Daily limits (prevent 24/7 bot farming) + +**CAN DEFER** (Optimize Later): +- Complex market makers +- Dynamic recipe costs +- Regional price variations +- Advanced anti-manipulation + +**Example - Minimal Stable Economy in 2 Hours**: +```python +class MinimalEconomy: + def __init__(self): + # Prices with bounds + self.prices = {'Iron': 10} + self.MIN_PRICE = 1 + self.MAX_PRICE = 100 + + def sell_to_npc(self, player, item, qty): + # NPC buys at 60% of market price (bid-ask spread) + revenue = self.prices[item] * 0.6 * qty + player.credits += revenue * 0.95 # 5% transaction fee (SINK) + + def buy_from_npc(self, player, item, qty): + cost = self.prices[item] * qty + if player.credits >= cost: + player.credits -= cost + return True + return False + + def update_price(self, item, demand, supply): + # Dampened adjustment (1.01x not 1.1x) + if demand > supply: + self.prices[item] *= 1.01 + else: + self.prices[item] *= 0.99 + + # Enforce bounds + self.prices[item] = clamp(self.prices[item], self.MIN_PRICE, self.MAX_PRICE) + + def validate_recipe(self, recipe): + # CRITICAL: Verify no infinite money + input_cost = sum(self.prices[mat] * qty for mat, qty in recipe.inputs.items()) + output_value = self.prices[recipe.output] * 0.6 # NPC buy price + + assert output_value < input_cost, f"Recipe {recipe.output} creates infinite money!" +``` + +This gives you: +- Money sinks (transaction fees) +- No arbitrage (bid-ask spread + validation) +- Stable prices (bounds + dampening) + +Refine later based on playtesting. + + +## Core Concepts + +### 1. Faucets and Sinks (Money Supply Control) + +Every game economy has **faucets** (money entering) and **sinks** (money leaving). The balance determines long-term stability. + +**Faucets** (Money Creation): +```python +# Examples of money entering the economy +class MoneyFaucets: + def new_player_bonus(self, player): + player.credits += 10000 # +10k per new player + + def quest_reward(self, player, quest): + player.credits += 5000 # +5k per quest + + def npc_mission(self, player): + player.credits += 1000 # +1k per mission + + def monster_drops(self, player, monster): + player.credits += 50 # +50 per kill + + def calculate_daily_faucet(self, player_count): + # Total money entering per day + new_players = 100 * 10000 # 1,000,000 + quests = player_count * 5 * 5000 # 5 quests/day + missions = player_count * 10 * 1000 # 10 missions/day + monsters = player_count * 100 * 50 # 100 kills/day + + return new_players + quests + missions + monsters +``` + +**Sinks** (Money Destruction): +```python +# Examples of money leaving the economy +class MoneySinks: + def transaction_fee(self, trade_value): + # 2% of every trade removed from game + fee = trade_value * 0.02 + # Credits disappear (not given to anyone) + return -fee + + def repair_cost(self, player, item): + # Items degrade, require repairs + cost = item.max_value * 0.1 + player.credits -= cost + + def consumable_purchase(self, player): + # Fuel, ammo, food - constant spending + player.credits -= 100 # Must buy from NPC (sinks) + + def luxury_items(self, player): + # Cosmetics, housing - pure sinks + player.credits -= 50000 + + def market_listing_fee(self, listing): + # Fee to list item on marketplace + return listing.value * 0.05 + + def calculate_daily_sink(self, player_count): + # Total money leaving per day + fees = player_count * 20 * 1000 * 0.02 # 20 trades/day + repairs = player_count * 5 * 500 # 5 repairs/day + consumables = player_count * 100 # Daily consumables + luxuries = player_count * 0.1 * 50000 # 10% buy luxury/day + + return fees + repairs + consumables + luxuries +``` + +**The Golden Rule**: +```python +def validate_economy_stability(faucets, sinks, player_count): + '''CRITICAL: Sinks must exceed faucets for stability''' + daily_faucet = calculate_daily_faucet(player_count) + daily_sink = calculate_daily_sink(player_count) + + ratio = daily_sink / daily_faucet + + if ratio < 0.8: + print("CRITICAL: Runaway inflation! Sinks too weak.") + print(f" Faucet: {daily_faucet}, Sink: {daily_sink}") + print(f" Ratio: {ratio:.2f} (need > 0.8)") + return False + + elif ratio > 1.2: + print("WARNING: Deflation! Sinks too strong.") + print(f" Players will run out of money.") + return False + + else: + print(f"HEALTHY: Sink/Faucet ratio = {ratio:.2f}") + return True +``` + +**Real-World Target**: +- **0.8-1.0**: Slight inflation (prices gradually rise) +- **1.0-1.1**: Stable (target for most games) +- **1.1-1.3**: Slight deflation (late-game sink) + +**Why This Matters**: +- Ratio < 0.8: Money supply grows exponentially → hyperinflation +- Ratio > 1.3: Money supply shrinks → players can't afford anything +- Ratio 0.9-1.1: Stable economy for months/years + +### 2. Arbitrage Prevention (No Free Money) + +**Arbitrage**: Buying low and selling high for guaranteed profit with no risk. + +**The Problem**: +```python +# ❌ BROKEN: Infinite money exploit +class BrokenEconomy: + def __init__(self): + self.npc_sell_price = 10 # NPC sells Iron for 10 + self.npc_buy_price = 10 # NPC buys Iron for 10 (SAME!) + + def exploit(self, player): + while True: + # Buy from NPC + player.credits -= 10 + player.inventory['Iron'] += 1 + + # Sell to NPC + player.inventory['Iron'] -= 1 + player.credits += 10 + + # Net: 0 profit (but with manufacturing...) + + # Craft: 50 Iron → 1 Hull (takes time but guaranteed) + if player.inventory['Iron'] >= 50: + player.inventory['Iron'] -= 50 + player.inventory['Hull'] += 1 + + # Sell Hull to NPC + player.inventory['Hull'] -= 1 + player.credits += 1000 # Hull sells for 1000 + + # Cost: 50 * 10 = 500 credits + # Revenue: 1000 credits + # Profit: 500 credits (100% guaranteed!) +``` + +**The Fix: Bid-Ask Spread**: +```python +# ✅ SAFE: NPCs buy low, sell high +class SafeEconomy: + def __init__(self): + self.market_price = 10 # "Fair" price + + # NPCs sell at premium + self.npc_sell_price = self.market_price * 1.0 # 10 (sell to players) + + # NPCs buy at discount + self.npc_buy_price = self.market_price * 0.6 # 6 (buy from players) + + def validate_no_arbitrage(self): + '''Verify all production chains are net-negative''' + recipes = { + 'Hull': {'Iron': 50, 'Silicon': 20}, + 'Electronics': {'Silicon': 30, 'Platinum': 10}, + } + + for output, inputs in recipes.items(): + # Calculate cost (buying materials from NPC) + input_cost = sum( + self.npc_sell_price[mat] * qty + for mat, qty in inputs.items() + ) + + # Calculate revenue (selling product to NPC) + output_revenue = self.npc_buy_price[output] + + profit = output_revenue - input_cost + + if profit > 0: + print(f"ERROR: {output} creates infinite money!") + print(f" Cost: {input_cost}, Revenue: {output_revenue}") + print(f" Profit: {profit} (should be negative!)") + raise ValueError(f"Recipe {output} is exploitable!") + + else: + print(f"SAFE: {output} requires player trading for profit") +``` + +**Why This Works**: +- Players MUST trade with each other to profit +- NPC loops are always net-negative +- Crafting is only profitable if you sell to players (creates real economy) + +**Bid-Ask Spread Guidelines**: +- **Conservative**: NPC buys at 50% (safe, forces player trading) +- **Moderate**: NPC buys at 60-70% (reasonable, still safe) +- **Aggressive**: NPC buys at 80% (risky, verify carefully!) +- **Never**: NPC buys at 100% (guaranteed exploits) + +### 3. Supply and Demand Dynamics + +**Basic Model**: +```python +class SupplyDemandMarket: + def __init__(self): + self.price = 10 + self.supply = 0 # Items available for sale + self.demand = 0 # Items players want to buy + + def update_price_simple(self): + '''Simple supply/demand adjustment''' + if self.demand > self.supply: + # Shortage: Price rises + self.price *= 1.05 + elif self.supply > self.demand: + # Surplus: Price falls + self.price *= 0.95 +``` + +**Problem**: This is unstable! Prices oscillate wildly. + +**Better: Dampened Adjustment**: +```python + def update_price_dampened(self): + '''Dampened adjustment for stability''' + # Calculate imbalance + if self.supply > 0: + ratio = self.demand / self.supply + else: + ratio = 10.0 # High demand, no supply + + # Dampened adjustment (smaller steps) + if ratio > 1.1: # Demand > Supply by 10%+ + self.price *= 1.01 # Increase 1% (not 5%!) + elif ratio < 0.9: # Supply > Demand by 10%+ + self.price *= 0.99 # Decrease 1% + + # Reset counters for next period + self.supply = 0 + self.demand = 0 +``` + +**Best: Exponential Moving Average**: +```python + def update_price_ema(self, recent_trades): + '''Smooth price adjustment using EMA''' + # Calculate average trade price from recent trades + if len(recent_trades) == 0: + return + + avg_trade_price = sum(t.price for t in recent_trades) / len(recent_trades) + + # Exponential moving average (smooth towards trade price) + SMOOTHING = 0.1 # 10% weight to new data + self.price = self.price * (1 - SMOOTHING) + avg_trade_price * SMOOTHING + + # Still enforce bounds + self.price = clamp(self.price, self.MIN_PRICE, self.MAX_PRICE) +``` + +**Advanced: Market Clearing Price**: +```python +class MarketClearingPrice: + def __init__(self): + self.buy_orders = [] # [(price, quantity), ...] + self.sell_orders = [] # [(price, quantity), ...] + + def find_clearing_price(self): + '''Find price where supply = demand''' + # Sort orders + self.buy_orders.sort(reverse=True) # Highest price first + self.sell_orders.sort() # Lowest price first + + # Walk through order book + for sell_price, sell_qty in self.sell_orders: + # Count how many buyers at this price or higher + demand_at_price = sum( + qty for price, qty in self.buy_orders + if price >= sell_price + ) + + # Count how many sellers at this price or lower + supply_at_price = sum( + qty for price, qty in self.sell_orders + if price <= sell_price + ) + + if demand_at_price >= supply_at_price: + # This is the clearing price! + return sell_price + + # No clearing price found + return None +``` + +**Decision Framework**: +- **Simple games**: Use dampened adjustment (easy, stable enough) +- **Trading-focused games**: Use market clearing price (realistic, complex) +- **Hybrid**: EMA for NPC prices, order book for player marketplace + +### 4. Price Bounds and Stabilization + +**Never Let Prices Go to Zero or Infinity**: +```python +class BoundedPricing: + def __init__(self, base_price): + self.price = base_price + self.MIN_PRICE = base_price * 0.1 # Never below 10% of base + self.MAX_PRICE = base_price * 10.0 # Never above 10x base + + def update_price(self, adjustment): + self.price *= adjustment + + # Enforce bounds (CRITICAL!) + self.price = clamp(self.price, self.MIN_PRICE, self.MAX_PRICE) + + # Alternative: Asymptotic bounds (smooth approach to limits) + # self.price = self.MIN_PRICE + (self.MAX_PRICE - self.MIN_PRICE) * ( + # 1 / (1 + math.exp(-(self.price - base_price) / base_price)) + # ) +``` + +**Why Bounds Matter**: +- No bounds: Price can explode to 1,000,000+ or crash to 0.0001 +- With bounds: Price stays playable (players can always afford basics) + +**NPC Market Makers** (Advanced Stabilization): +```python +class NPCMarketMaker: + def __init__(self, target_price, liquidity): + self.target_price = target_price + self.liquidity = liquidity # How much NPC will trade + + def provide_liquidity(self, market): + '''NPC always offers to buy/sell at near-target price''' + # NPC sells at slight premium + npc_sell_price = self.target_price * 1.05 + npc_sell_quantity = self.liquidity + + # NPC buys at slight discount + npc_buy_price = self.target_price * 0.95 + npc_buy_quantity = self.liquidity + + # Add NPC orders to market + market.add_sell_order(npc_sell_price, npc_sell_quantity, is_npc=True) + market.add_buy_order(npc_buy_price, npc_buy_quantity, is_npc=True) + + def adjust_target(self, current_price): + '''NPC slowly adjusts target towards market price''' + SMOOTHING = 0.01 # Very slow adjustment + self.target_price = self.target_price * (1 - SMOOTHING) + current_price * SMOOTHING +``` + +**Effect**: +- Players can always buy/sell (NPC provides liquidity) +- Prices stabilize near target (NPC absorbs shocks) +- Market still responds to player activity (target adjusts slowly) + +### 5. Production Chain Balance + +**Every recipe must be balanced for profitability and gameplay**: + +```python +class ProductionChainBalancer: + def __init__(self, market): + self.market = market + + def analyze_chain(self, recipe): + '''Analyze profitability and balance of a recipe''' + # Calculate input cost (buying from players at market price) + input_cost_market = sum( + self.market.get_price(mat) * qty + for mat, qty in recipe.inputs.items() + ) + + # Calculate input cost (buying from NPCs) + input_cost_npc = sum( + self.market.npc_sell_price(mat) * qty + for mat, qty in recipe.inputs.items() + ) + + # Calculate output value (selling to players) + output_value_market = self.market.get_price(recipe.output) + + # Calculate output value (selling to NPCs) + output_value_npc = self.market.npc_buy_price(recipe.output) + + # Calculate time cost + time_hours = recipe.crafting_time / 3600 # Convert to hours + + # Profit analysis + profit_vs_npc = output_value_npc - input_cost_npc + profit_vs_players = output_value_market - input_cost_market + + profit_per_hour_npc = profit_vs_npc / time_hours + profit_per_hour_players = profit_vs_players / time_hours + + print(f"\nRecipe: {recipe.output}") + print(f" Input cost (NPC): {input_cost_npc:.0f}") + print(f" Output value (NPC): {output_value_npc:.0f}") + print(f" Profit vs NPC: {profit_vs_npc:.0f} (should be NEGATIVE)") + print(f" Profit vs Players: {profit_vs_players:.0f}") + print(f" Profit/hour (players): {profit_per_hour_players:.0f}") + + # Validation + if profit_vs_npc > 0: + print(f" ⚠️ ERROR: Creates infinite money vs NPCs!") + return False + + if profit_vs_players < 0: + print(f" ⚠️ WARNING: Unprofitable even with player trading!") + + if profit_per_hour_players < 100: + print(f" ⚠️ WARNING: Poor profit/hour (players won't craft)") + + return True + + def balance_all_chains(self, recipes): + '''Ensure all recipes have similar profit/hour''' + profits = [] + + for recipe in recipes: + # Calculate profit per hour vs player market + input_cost = sum( + self.market.get_price(mat) * qty + for mat, qty in recipe.inputs.items() + ) + output_value = self.market.get_price(recipe.output) + time_hours = recipe.crafting_time / 3600 + + profit_per_hour = (output_value - input_cost) / time_hours + profits.append((recipe.output, profit_per_hour)) + + # Sort by profit + profits.sort(key=lambda x: x[1]) + + print("\nProduction Chain Balance:") + for name, profit in profits: + print(f" {name}: {profit:.0f} credits/hour") + + # Check for dominant strategies + max_profit = profits[-1][1] + min_profit = profits[0][1] + + if max_profit > min_profit * 3: + print(f" ⚠️ WARNING: Imbalanced chains!") + print(f" Best: {profits[-1][0]} ({max_profit:.0f}/hr)") + print(f" Worst: {profits[0][0]} ({min_profit:.0f}/hr)") + print(f" Ratio: {max_profit / min_profit:.1f}x (should be < 3x)") +``` + +**Balance Guidelines**: +- All chains should have similar profit/hour (within 2-3x) +- No chain should be strictly better (dominant strategy) +- Complex chains can pay more (reward skill/knowledge) +- Late-game chains should be more profitable (progression) + + +## Decision Frameworks + +### Framework 1: Economy Complexity Level + +**Choose complexity based on game focus**: + +| Complexity | Use When | Examples | Implementation Time | +|-----------|----------|----------|---------------------| +| **Simple** | Economy is secondary to core gameplay | Action RPG, FPS | 1-2 days | +| **Moderate** | Trading is important but not central | MMO, Strategy | 1-2 weeks | +| **Complex** | Economy IS the game | Eve Online, Trading sim | 1-3 months | + +**Simple Economy**: +```python +# Fixed prices, transaction fees, consumables +class SimpleEconomy: + PRICES = {'Sword': 100, 'Potion': 10} # Fixed + + def buy_from_npc(self, player, item): + player.credits -= self.PRICES[item] + + def sell_to_npc(self, player, item): + player.credits += self.PRICES[item] * 0.5 # 50% back +``` + +**Features**: +- Fixed prices (no supply/demand) +- NPC vendors only +- Simple sinks (repair, consumables) +- No player trading + +**Use for**: Games where economy is flavor, not focus + +**Moderate Economy**: +```python +# Dynamic prices, player marketplace, production +class ModerateEconomy: + def __init__(self): + self.prices = {} # Dynamic + self.player_market = OrderBook() + + def update_prices(self, trades): + # EMA based on recent trades + for item in trades: + self.prices[item] = ema(self.prices[item], trades[item].avg_price) + + def npc_buy_price(self, item): + return self.prices[item] * 0.6 # 60% of market + + def npc_sell_price(self, item): + return self.prices[item] * 1.0 # 100% of market +``` + +**Features**: +- Dynamic prices (supply/demand) +- Player marketplace (peer trading) +- Production chains (crafting) +- Faucet/sink balance + +**Use for**: MMOs, persistent world games + +**Complex Economy**: +```python +# Full market simulation, regional prices, contracts +class ComplexEconomy: + def __init__(self): + self.regions = {} # Different prices per region + self.order_books = {} # Full order matching + self.contracts = [] # Player contracts + self.corporations = [] # Player organizations + + def match_orders(self, item, region): + '''Full order book matching''' + book = self.order_books[(item, region)] + clearing_price = book.find_clearing_price() + book.execute_trades(clearing_price) + + def transport_goods(self, item, from_region, to_region): + '''Regional arbitrage (hauling gameplay)''' + # Different prices in different regions + # Players profit by hauling goods + pass +``` + +**Features**: +- Regional economies (different prices per zone) +- Full order book matching +- Player contracts and corporations +- Hauling/transport gameplay +- Complex production chains + +**Use for**: Eve Online-style games, trading simulators + +### Framework 2: Player-Driven vs NPC-Driven Markets + +**NPC-Driven** (Simpler, More Stable): +```python +class NPCDrivenMarket: + '''NPCs set prices, players buy/sell from NPCs''' + def __init__(self): + self.npc_prices = {} # NPCs always available + + def buy_from_npc(self, player, item, qty): + cost = self.npc_prices[item] * qty + if player.credits >= cost: + player.credits -= cost + player.inventory[item] += qty + return True + return False + + def sell_to_npc(self, player, item, qty): + revenue = self.npc_prices[item] * 0.6 * qty # 60% back + player.credits += revenue + player.inventory[item] -= qty +``` + +**Pros**: +- Always available (no market failures) +- Stable prices (predictable) +- Simple to implement +- Works for small populations + +**Cons**: +- Less dynamic +- No emergent gameplay +- Artificial feeling + +**Use for**: Single-player, co-op, small multiplayer + +**Player-Driven** (Complex, Dynamic): +```python +class PlayerDrivenMarket: + '''Players set prices, NPCs only provide liquidity''' + def __init__(self): + self.order_book = OrderBook() + self.npc_maker = NPCMarketMaker() # Backup liquidity + + def create_sell_order(self, seller, item, qty, price): + '''Player lists item for sale''' + order = {'seller': seller, 'item': item, 'qty': qty, 'price': price} + self.order_book.add_sell_order(order) + + def create_buy_order(self, buyer, item, qty, max_price): + '''Player offers to buy at price''' + order = {'buyer': buyer, 'item': item, 'qty': qty, 'price': max_price} + self.order_book.add_buy_order(order) + + def match_orders(self): + '''Match buy and sell orders''' + self.order_book.match() + + # If no liquidity, NPC provides backup + if self.order_book.is_empty(): + self.npc_maker.provide_liquidity(self.order_book) +``` + +**Pros**: +- Emergent gameplay (market manipulation, speculation) +- Dynamic prices (responds to player behavior) +- Engaging economy (players set prices) +- Scales to large populations + +**Cons**: +- Can fail (no buyers/sellers) +- Requires balancing +- Complex implementation +- Needs large player base + +**Use for**: MMOs, persistent worlds, large multiplayer + +**Hybrid** (Best of Both): +```python +class HybridMarket: + '''Players trade with each other, NPCs provide fallback''' + def __init__(self): + self.player_market = OrderBook() + self.npc_vendor = NPCVendor() + + def buy_item(self, player, item, qty): + '''Try player market first, fall back to NPC''' + # Check player market + orders = self.player_market.get_sell_orders(item) + if len(orders) > 0: + # Buy from cheapest player + cheapest = min(orders, key=lambda o: o.price) + if player.credits >= cheapest.price * qty: + self.execute_player_trade(player, cheapest, qty) + return True + + # Fall back to NPC (more expensive) + return self.npc_vendor.buy_from_npc(player, item, qty) +``` + +**Use for**: Most games (combines stability + dynamics) + +### Framework 3: When to Use Regional Economies + +**Single Global Market** (Simpler): +- All players see same prices +- No hauling gameplay +- Works for small games + +**Regional Markets** (Complex): +- Different prices per zone +- Hauling creates arbitrage opportunities +- Requires large world and population + +**Decision Table**: + +| Factor | Global Market | Regional Markets | +|--------|---------------|------------------| +| World size | Small (<10 zones) | Large (>20 zones) | +| Player count | <1,000 | >5,000 | +| Travel time | <1 minute | >5 minutes | +| Hauling gameplay | No | Yes (core mechanic) | +| Implementation time | 1 week | 1 month | + +**Regional Economy Example**: +```python +class RegionalEconomy: + def __init__(self): + self.regions = { + 'Mining Zone': {'Iron': 5, 'Ships': 4000}, # Iron cheap, ships expensive + 'Industrial Zone': {'Iron': 15, 'Ships': 3000}, # Iron expensive, ships cheaper + 'Trade Hub': {'Iron': 10, 'Ships': 3500}, # Average prices + } + + def get_price(self, region, item): + return self.regions[region][item] + + def arbitrage_opportunity(self, item): + '''Find best buy/sell regions for hauling''' + prices = [(region, self.get_price(region, item)) for region in self.regions] + prices.sort(key=lambda x: x[1]) + + buy_region, buy_price = prices[0] # Cheapest + sell_region, sell_price = prices[-1] # Most expensive + + profit_per_unit = sell_price - buy_price + print(f"Haul {item} from {buy_region} to {sell_region}") + print(f" Profit: {profit_per_unit} per unit") + + return buy_region, sell_region, profit_per_unit +``` + + +## Implementation Patterns + +### Pattern 1: Faucet/Sink Validation System + +**Complete validation for economic stability**: + +```python +class EconomyValidator: + def __init__(self, economy, player_count): + self.economy = economy + self.player_count = player_count + + def validate_stability(self, simulation_days=30): + '''Simulate economy for N days to check stability''' + print(f"Simulating economy for {simulation_days} days...") + + total_money = self.calculate_initial_money() + daily_faucet = self.calculate_daily_faucet() + daily_sink = self.calculate_daily_sink() + + print(f"\nInitial money supply: {total_money:,}") + print(f"Daily faucet: {daily_faucet:,}") + print(f"Daily sink: {daily_sink:,}") + print(f"Sink/Faucet ratio: {daily_sink/daily_faucet:.2f}") + + # Simulate + money_over_time = [total_money] + for day in range(simulation_days): + total_money += daily_faucet + total_money -= daily_sink + money_over_time.append(total_money) + + # Analyze + final_money = money_over_time[-1] + money_growth_rate = (final_money / money_over_time[0]) ** (1/simulation_days) - 1 + + print(f"\nAfter {simulation_days} days:") + print(f" Total money: {final_money:,}") + print(f" Growth rate: {money_growth_rate*100:.2f}% per day") + + # Check for inflation + if money_growth_rate > 0.01: # >1% growth per day + print(f" ⚠️ WARNING: Runaway inflation!") + print(f" Money supply growing too fast.") + print(f" Increase sinks or reduce faucets.") + return False + + elif money_growth_rate < -0.01: # <-1% shrink per day + print(f" ⚠️ WARNING: Deflation!") + print(f" Money supply shrinking.") + print(f" Reduce sinks or increase faucets.") + return False + + else: + print(f" ✓ STABLE: Money supply growth is healthy.") + return True + + def calculate_initial_money(self): + '''Money already in economy''' + return self.player_count * 50000 # Average per player + + def calculate_daily_faucet(self): + '''Money entering per day''' + new_players = 100 * 10000 # New player bonuses + quests = self.player_count * 5 * 5000 # Quests per player + missions = self.player_count * 10 * 1000 # Missions + monster_drops = self.player_count * 100 * 50 # Combat + + return new_players + quests + missions + monster_drops + + def calculate_daily_sink(self): + '''Money leaving per day''' + transaction_fees = self.player_count * 20 * 1000 * 0.02 # 20 trades/day, 2% fee + repairs = self.player_count * 5 * 500 # 5 repairs/day + consumables = self.player_count * 100 # Daily ammo/fuel + listing_fees = self.player_count * 5 * 1000 * 0.05 # Market listings + + return transaction_fees + repairs + consumables + listing_fees + + def validate_no_arbitrage(self): + '''Ensure no infinite money loops''' + print("\nValidating production chains...") + + for recipe in self.economy.recipes: + input_cost_npc = sum( + self.economy.npc_sell_price(mat) * qty + for mat, qty in recipe.inputs.items() + ) + + output_value_npc = self.economy.npc_buy_price(recipe.output) + + profit = output_value_npc - input_cost_npc + + if profit > 0: + print(f" ❌ ERROR: {recipe.output} creates infinite money!") + print(f" Input cost: {input_cost_npc}") + print(f" Output value: {output_value_npc}") + print(f" Profit: {profit}") + return False + else: + print(f" ✓ {recipe.output}: Safe (requires player trading)") + + return True + + def validate_price_bounds(self): + '''Ensure prices have min/max''' + print("\nValidating price bounds...") + + for item in self.economy.items: + if not hasattr(item, 'min_price') or not hasattr(item, 'max_price'): + print(f" ❌ ERROR: {item.name} missing price bounds!") + return False + + if item.min_price <= 0: + print(f" ❌ ERROR: {item.name} min_price is {item.min_price} (must be > 0)") + return False + + if item.max_price < item.min_price * 5: + print(f" ⚠️ WARNING: {item.name} max_price too close to min") + print(f" Range: {item.min_price} - {item.max_price}") + + print(f" ✓ {item.name}: {item.min_price} - {item.max_price}") + + return True + + def run_full_validation(self): + '''Run all validation checks''' + print("="*60) + print("ECONOMIC STABILITY VALIDATION") + print("="*60) + + checks = [ + ("Stability", self.validate_stability), + ("Arbitrage", self.validate_no_arbitrage), + ("Price Bounds", self.validate_price_bounds), + ] + + results = [] + for name, check in checks: + print(f"\n{name} Check:") + passed = check() + results.append((name, passed)) + + print("\n" + "="*60) + print("VALIDATION SUMMARY") + print("="*60) + + all_passed = all(passed for _, passed in results) + + for name, passed in results: + status = "✓ PASS" if passed else "❌ FAIL" + print(f"{status}: {name}") + + if all_passed: + print("\n✓ Economy is stable and ready for production!") + else: + print("\n❌ Economy has critical issues. Fix before launch!") + + return all_passed +``` + +### Pattern 2: Dynamic Price Adjustment with Stabilization + +**Robust price adjustment system**: + +```python +class StabilizedPricing: + def __init__(self, item_name, base_price): + self.item_name = item_name + self.base_price = base_price + self.current_price = base_price + + # Bounds (10% to 10x base) + self.min_price = base_price * 0.1 + self.max_price = base_price * 10.0 + + # Smoothing parameters + self.ema_alpha = 0.1 # 10% weight to new data + self.adjustment_rate = 0.01 # 1% change per update + + # History for analysis + self.price_history = [] + self.trade_history = [] + + def update_from_supply_demand(self, supply, demand): + '''Update price based on supply/demand imbalance''' + if supply == 0 and demand == 0: + return # No activity + + if supply == 0: + # Pure demand, no supply + self.current_price *= (1 + self.adjustment_rate) + + elif demand == 0: + # Pure supply, no demand + self.current_price *= (1 - self.adjustment_rate) + + else: + # Calculate ratio + ratio = demand / supply + + if ratio > 1.1: # 10% more demand than supply + # Increase price (dampened) + self.current_price *= (1 + self.adjustment_rate) + + elif ratio < 0.9: # 10% more supply than demand + # Decrease price (dampened) + self.current_price *= (1 - self.adjustment_rate) + + # Enforce bounds + self.current_price = clamp(self.current_price, self.min_price, self.max_price) + + # Record + self.price_history.append(self.current_price) + + def update_from_trades(self, trades): + '''Update price based on actual trade prices (more accurate)''' + if len(trades) == 0: + return + + # Calculate average trade price + avg_trade_price = sum(t.price * t.quantity for t in trades) / sum(t.quantity for t in trades) + + # Exponential moving average + self.current_price = ( + self.current_price * (1 - self.ema_alpha) + + avg_trade_price * self.ema_alpha + ) + + # Enforce bounds + self.current_price = clamp(self.current_price, self.min_price, self.max_price) + + # Record + self.price_history.append(self.current_price) + self.trade_history.extend(trades) + + def get_volatility(self): + '''Calculate recent price volatility''' + if len(self.price_history) < 10: + return 0.0 + + recent = self.price_history[-10:] + mean = sum(recent) / len(recent) + variance = sum((p - mean)**2 for p in recent) / len(recent) + std_dev = variance ** 0.5 + + return std_dev / mean # Coefficient of variation + + def should_intervene(self): + '''Check if NPC intervention is needed''' + volatility = self.get_volatility() + + # High volatility: Market unstable + if volatility > 0.2: # 20% volatility + return True + + # Price at extremes + if self.current_price <= self.min_price * 1.1: + return True # Near floor + if self.current_price >= self.max_price * 0.9: + return True # Near ceiling + + return False +``` + +### Pattern 3: NPC Market Maker for Stabilization + +**NPCs provide liquidity when markets fail**: + +```python +class NPCMarketMaker: + def __init__(self, item, target_price, liquidity_amount): + self.item = item + self.target_price = target_price + self.liquidity_amount = liquidity_amount # Max qty NPC will trade + + # Spread (NPC buys low, sells high) + self.bid_spread = 0.95 # NPC buys at 95% of target + self.ask_spread = 1.05 # NPC sells at 105% of target + + def get_npc_buy_order(self): + '''NPC standing offer to buy''' + price = self.target_price * self.bid_spread + quantity = self.liquidity_amount + + return { + 'item': self.item, + 'price': price, + 'quantity': quantity, + 'is_npc': True + } + + def get_npc_sell_order(self): + '''NPC standing offer to sell''' + price = self.target_price * self.ask_spread + quantity = self.liquidity_amount + + return { + 'item': self.item, + 'price': price, + 'quantity': quantity, + 'is_npc': True + } + + def adjust_target_price(self, current_market_price): + '''Slowly adjust target towards market price''' + # Very slow adjustment (1% per update) + ADJUSTMENT_RATE = 0.01 + + self.target_price = ( + self.target_price * (1 - ADJUSTMENT_RATE) + + current_market_price * ADJUSTMENT_RATE + ) + + def intervene_if_needed(self, market): + '''Provide liquidity if market is thin''' + order_book = market.get_order_book(self.item) + + # Check if market has liquidity + total_buy_orders = sum(o.quantity for o in order_book.buy_orders if not o.is_npc) + total_sell_orders = sum(o.quantity for o in order_book.sell_orders if not o.is_npc) + + # If market is thin, add NPC orders + if total_buy_orders < self.liquidity_amount * 0.5: + market.add_buy_order(self.get_npc_buy_order()) + + if total_sell_orders < self.liquidity_amount * 0.5: + market.add_sell_order(self.get_npc_sell_order()) +``` + +**Effect**: +- Market always has liquidity (players can always trade) +- Prices stabilize near target (NPC absorbs volatility) +- NPC adapts to market (target adjusts slowly) + +### Pattern 4: Production Chain Simulator + +**Test economic balance before launch**: + +```python +class ProductionChainSimulator: + def __init__(self, economy): + self.economy = economy + + def simulate_player_behavior(self, num_players=1000, days=30): + '''Simulate player economy for N days''' + print(f"Simulating {num_players} players for {days} days...\n") + + # Track metrics + total_money = num_players * 10000 # Starting credits + resource_production = {item: 0 for item in self.economy.resources} + goods_production = {item: 0 for item in self.economy.goods} + trade_volume = 0 + + for day in range(days): + # Players mine resources + for resource in self.economy.resources: + daily_mining = num_players * 100 # 100 units/player/day + resource_production[resource] += daily_mining + + # Players craft (choose most profitable chain) + best_recipe = self.find_best_recipe() + if best_recipe: + crafters = num_players * 0.3 # 30% of players craft + daily_crafting = crafters * 2 # 2 items/day + goods_production[best_recipe.output] += daily_crafting + + # Consume resources + for mat, qty in best_recipe.inputs.items(): + resource_production[mat] -= daily_crafting * qty + + # Players trade + daily_trades = num_players * 10 # 10 trades/player/day + avg_trade_value = 1000 + trade_volume += daily_trades * avg_trade_value + + # Money faucets + daily_faucet = self.economy.calculate_daily_faucet(num_players) + total_money += daily_faucet + + # Money sinks + daily_sink = self.economy.calculate_daily_sink(num_players) + total_money -= daily_sink + + # Log every 5 days + if day % 5 == 0: + print(f"Day {day}:") + print(f" Total money: {total_money:,}") + print(f" Trade volume: {trade_volume:,}") + print(f" Most produced: {max(goods_production, key=goods_production.get)}") + + # Final report + print(f"\n{'='*60}") + print(f"SIMULATION COMPLETE ({days} days)") + print(f"{'='*60}") + + print(f"\nMoney Supply:") + print(f" Initial: {num_players * 10000:,}") + print(f" Final: {total_money:,}") + print(f" Growth: {(total_money / (num_players * 10000) - 1) * 100:.1f}%") + + print(f"\nResource Production:") + for resource, qty in resource_production.items(): + print(f" {resource}: {qty:,}") + + print(f"\nGoods Production:") + for good, qty in goods_production.items(): + print(f" {good}: {qty:,}") + + # Check for imbalances + self.check_for_imbalances(resource_production, goods_production) + + def find_best_recipe(self): + '''Find most profitable recipe (dominant strategy)''' + best_recipe = None + best_profit_per_hour = 0 + + for recipe in self.economy.recipes: + input_cost = sum( + self.economy.get_price(mat) * qty + for mat, qty in recipe.inputs.items() + ) + output_value = self.economy.get_price(recipe.output) + time_hours = recipe.time / 3600 + + profit_per_hour = (output_value - input_cost) / time_hours + + if profit_per_hour > best_profit_per_hour: + best_profit_per_hour = profit_per_hour + best_recipe = recipe + + return best_recipe + + def check_for_imbalances(self, resource_production, goods_production): + '''Detect economic imbalances''' + print(f"\nImbalance Detection:") + + # Check for unused resources + for resource, qty in resource_production.items(): + if qty > 100000: # Excess production + print(f" ⚠️ {resource}: Overproduced ({qty:,} units)") + print(f" Consider increasing demand or decreasing production.") + + if qty < 0: # Deficit + print(f" ⚠️ {resource}: Deficit ({abs(qty):,} units)") + print(f" Production can't keep up with demand.") + + # Check for dominant strategies + if len(goods_production) > 0: + max_produced = max(goods_production.values()) + min_produced = min(goods_production.values()) + + if max_produced > min_produced * 5: # 5x imbalance + print(f" ⚠️ Production imbalance detected!") + print(f" Most produced: {max(goods_production, key=goods_production.get)}") + print(f" Least produced: {min(goods_production, key=goods_production.get)}") + print(f" Ratio: {max_produced/min_produced:.1f}x") +``` + +### Pattern 5: Anti-Exploit Validation + +**Comprehensive exploit detection**: + +```python +class ExploitDetector: + def __init__(self, economy): + self.economy = economy + + def find_arbitrage_loops(self): + '''Detect cycles in production/trading that generate profit''' + print("Searching for arbitrage loops...\n") + + loops_found = [] + + # Check direct buy-sell loops + for item in self.economy.items: + npc_sell = self.economy.npc_sell_price(item) + npc_buy = self.economy.npc_buy_price(item) + + if npc_buy >= npc_sell: + print(f"❌ CRITICAL: {item} - Direct arbitrage!") + print(f" NPC sells for: {npc_sell}") + print(f" NPC buys for: {npc_buy}") + print(f" Profit: {npc_buy - npc_sell} per unit") + loops_found.append(('direct', item)) + + # Check production loops + for recipe in self.economy.recipes: + input_cost = sum( + self.economy.npc_sell_price(mat) * qty + for mat, qty in recipe.inputs.items() + ) + output_value = self.economy.npc_buy_price(recipe.output) + + if output_value > input_cost: + profit = output_value - input_cost + print(f"❌ CRITICAL: {recipe.output} - Production arbitrage!") + print(f" Input cost: {input_cost}") + print(f" Output value: {output_value}") + print(f" Profit: {profit}") + loops_found.append(('production', recipe.output)) + + # Check multi-step loops (A→B→C→A) + # This requires graph traversal to find cycles + loops_found.extend(self.find_conversion_cycles()) + + if len(loops_found) == 0: + print("✓ No arbitrage loops found!") + else: + print(f"\n❌ Found {len(loops_found)} exploitable loops!") + + return loops_found + + def find_conversion_cycles(self): + '''Find multi-step conversion cycles''' + # Build conversion graph + graph = {} + for recipe in self.economy.recipes: + # Each recipe is an edge: inputs → output + graph[recipe.output] = recipe.inputs + + # DFS to find cycles + cycles = [] + # ... graph traversal logic ... + return cycles + + def test_market_manipulation(self): + '''Simulate coordinated buying to check for cornering''' + print("\nTesting market manipulation resistance...\n") + + for item in self.economy.items: + # Simulate 100 players buying entire supply + available_supply = self.economy.get_total_supply(item) + market_price = self.economy.get_price(item) + + # Cost to buy entire supply + total_cost = available_supply * market_price + + # Price after buying + new_price = self.economy.simulate_price_after_buying(item, available_supply) + + price_increase = (new_price / market_price - 1) * 100 + + if price_increase > 500: # 5x price increase + print(f"⚠️ {item}: Vulnerable to cornering!") + print(f" Supply: {available_supply}") + print(f" Cost to corner: {total_cost:,}") + print(f" Price increase: {price_increase:.0f}%") + print(f" Recommendation: Increase supply or add price ceiling") + + print("\n✓ Market manipulation test complete") + + def test_duping_detection(self): + '''Verify transactions are atomic and validated''' + print("\nTesting duping prevention...\n") + + # Check if trades are atomic + if not hasattr(self.economy, 'execute_trade_atomic'): + print("⚠️ WARNING: No atomic trade execution found!") + print(" Implement database transactions to prevent duping") + + # Check if inventory is validated + if not hasattr(self.economy, 'validate_inventory'): + print("⚠️ WARNING: No inventory validation found!") + print(" Add checks for negative quantities and overflow") + + print("✓ Duping prevention checks complete") + + def run_all_exploit_tests(self): + '''Run comprehensive exploit detection''' + print("="*60) + print("EXPLOIT DETECTION SUITE") + print("="*60 + "\n") + + tests = [ + ("Arbitrage Loops", self.find_arbitrage_loops), + ("Market Manipulation", self.test_market_manipulation), + ("Duping Prevention", self.test_duping_detection), + ] + + exploits_found = [] + + for name, test in tests: + print(f"\n{name}:") + print("-" * 40) + result = test() + if result: + exploits_found.extend(result) + + print("\n" + "="*60) + print("EXPLOIT DETECTION SUMMARY") + print("="*60) + + if len(exploits_found) == 0: + print("✓ No exploits detected!") + else: + print(f"❌ Found {len(exploits_found)} potential exploits!") + print("Fix these before launch!") + + return exploits_found +``` + + +## Common Pitfalls + +### Pitfall 1: No Money Sinks (Runaway Inflation) + +**The Mistake**: +```python +# ❌ Money enters but never leaves +class BrokenEconomy: + def new_player_joins(self, player): + player.credits += 10000 # Faucet + + def complete_quest(self, player): + player.credits += 5000 # Faucet + + # NO SINKS! Money accumulates forever +``` + +**Why It Fails**: +- Every player brings 10,000 credits +- Quests add 5,000 credits each +- After 1 month: Millions of excess credits +- Hyperinflation: Prices skyrocket +- New players can't afford anything + +**Real-World Example**: Early World of Warcraft had insufficient gold sinks. Players accumulated millions of gold, causing runaway inflation. Blizzard added repair costs, mounts, and consumables to drain gold. + +**The Fix**: +```python +# ✅ Balance faucets with sinks +class StableEconomy: + def calculate_faucets(self, player_count): + daily_faucet = player_count * 10000 # New players + quests + return daily_faucet + + def calculate_sinks(self, player_count): + # Transaction fees + daily_trades = player_count * 10 + trade_fee = daily_trades * 1000 * 0.02 + + # Repairs + daily_repairs = player_count * 5 * 500 + + # Consumables (ammo, fuel) + daily_consumables = player_count * 100 + + # Listing fees + daily_listings = player_count * 5 * 1000 * 0.05 + + daily_sink = trade_fee + daily_repairs + daily_consumables + daily_listings + return daily_sink + + def validate_balance(self, player_count): + faucet = self.calculate_faucets(player_count) + sink = self.calculate_sinks(player_count) + ratio = sink / faucet + + assert 0.8 <= ratio <= 1.2, f"Sink/Faucet ratio is {ratio} (need 0.8-1.2)" +``` + +**Critical Sinks to Include**: +- Transaction fees (1-5%) +- Repair costs (items degrade) +- Consumables (ammo, fuel, food) +- Listing fees (marketplace) +- Fast travel costs +- Housing/storage fees +- NPC luxury items (cosmetics) + +### Pitfall 2: NPC Arbitrage (Infinite Money Loops) + +**The Mistake**: +```python +# ❌ NPCs buy and sell at same price +class ExploitableEconomy: + def npc_sell_price(self, item): + return self.market_price[item] + + def npc_buy_price(self, item): + return self.market_price[item] # SAME! + + # Players discover: + # Mine 50 Iron (free) → Craft Hull → Sell to NPC + # Input: 50 Iron @ 10 = 500 credits + # Output: 1 Hull @ 1000 = 1000 credits + # Profit: 500 credits (INFINITE LOOP!) +``` + +**Why It Fails**: +- Players find optimal craft chains +- Everyone grinds the same exploit +- Economy becomes meaningless (everyone prints money) +- Hyperinflation accelerates + +**Real-World Example**: Path of Exile had a vendor recipe that generated profit. Players automated it, crashing the economy. GGG nerfed the recipe. + +**The Fix**: +```python +# ✅ NPCs buy at discount (bid-ask spread) +class SafeEconomy: + def npc_sell_price(self, item): + return self.market_price[item] * 1.0 # 100% (sell to players) + + def npc_buy_price(self, item): + return self.market_price[item] * 0.6 # 60% (buy from players) + + def validate_no_arbitrage(self): + for recipe in self.recipes: + input_cost = sum( + self.npc_sell_price(mat) * qty + for mat, qty in recipe.inputs.items() + ) + output_value = self.npc_buy_price(recipe.output) + + if output_value >= input_cost: + raise ValueError(f"{recipe.output} creates infinite money!") +``` + +**Bid-Ask Spread Guidelines**: +- **Safe**: NPCs buy at 50-60% (forces player trading) +- **Moderate**: NPCs buy at 60-70% +- **Risky**: NPCs buy at 80%+ (validate carefully!) + +### Pitfall 3: Unbounded Prices (Wild Swings) + +**The Mistake**: +```python +# ❌ Prices multiply with no bounds +class UnstablePrices: + def update_price(self, item, demand, supply): + if demand > supply: + self.price[item] *= 1.1 # +10% every update + + # After 50 updates: 10 * (1.1^50) = 1,173 credits! + # After 100 updates: 137,806 credits (unusable) +``` + +**Why It Fails**: +- Prices can explode to infinity or crash to zero +- Market becomes unusable +- Players can't plan or budget +- Coordinated manipulation causes chaos + +**Real-World Example**: Eve Online had periods of extreme price volatility before implementing market stabilization tools. + +**The Fix**: +```python +# ✅ Bounded prices with dampening +class StablePrices: + def __init__(self, base_price): + self.price = base_price + self.min_price = base_price * 0.1 # Floor: 10% of base + self.max_price = base_price * 10.0 # Ceiling: 10x base + + def update_price(self, demand, supply): + if demand > supply: + self.price *= 1.01 # Dampened: +1% not +10% + else: + self.price *= 0.99 # Dampened: -1% + + # Enforce bounds + self.price = clamp(self.price, self.min_price, self.max_price) +``` + +**Key fixes**: +- Price floors (minimum > 0) +- Price ceilings (maximum < infinity) +- Dampening (1.01x adjustments, not 1.1x) +- Smoothing (EMA, not instant) + +### Pitfall 4: Unbalanced Production Chains + +**The Mistake**: +```python +# ❌ Some chains way more profitable than others +class ImbalancedChains: + # Hull: 800 cost → 1000 value = 200 profit (25%) + # Electronics: 950 cost → 800 value = -150 profit (LOSS!) + # Fuel Cell: 280 cost → 500 value = 220 profit (79%!) + + # Result: Everyone crafts Fuel Cells, nobody crafts Electronics +``` + +**Why It Fails**: +- Dominant strategies emerge (one chain is best) +- Other chains become useless +- Market distorts (excess supply of one item) +- Ships can't be built (missing Electronics) + +**Real-World Example**: Final Fantasy XIV regularly rebalances crafting recipes to ensure all disciplines are equally profitable. + +**The Fix**: +```python +# ✅ Analyze and balance all chains +class BalancedChains: + def analyze_profit_margins(self): + for recipe in self.recipes: + input_cost = sum(self.price[mat] * qty for mat, qty in recipe.inputs.items()) + output_value = self.price[recipe.output] + time_hours = recipe.time / 3600 + + profit_per_hour = (output_value - input_cost) / time_hours + + print(f"{recipe.output}: {profit_per_hour:.0f} credits/hour") + + # Ensure all chains have similar profit/hour (within 2-3x) + + def balance_recipe(self, recipe, target_profit_per_hour): + current_profit = self.calculate_profit_per_hour(recipe) + + if current_profit < target_profit_per_hour: + # Increase output value or decrease input cost + multiplier = target_profit_per_hour / current_profit + recipe.output_quantity *= multiplier +``` + +**Balance targets**: +- All chains should be within 2-3x profit/hour +- No chain should be strictly better (dominant strategy) +- Complex chains can pay more (reward knowledge) + +### Pitfall 5: No Velocity Control (Resource Flood) + +**The Mistake**: +```python +# ❌ Infinite resource production, no consumption +class ResourceFlood: + def mine(self, player, resource): + # 100 units/hour per player + # With 1000 players: 2.4M units/day + # After 30 days: 72M units (infinite!) + player.inventory[resource] += 100 +``` + +**Why It Fails**: +- Resources accumulate infinitely +- Supply >> demand always +- Prices crash to near-zero +- Mining becomes pointless (market flooded) + +**Real-World Example**: Runescape had resource inflation for years. They added item sinks (high-level equipment degrades and must be repaired with resources). + +**The Fix**: +```python +# ✅ Add resource sinks +class ControlledVelocity: + def consume_fuel(self, player): + # Travel requires fuel + player.inventory['Fuel'] -= 10 + + def repair_ship(self, player, ship): + # Repairs consume resources + player.inventory['Iron'] -= 20 + + def decay_food(self, player): + # Food spoils over time + for item in player.inventory: + if item.type == 'food': + item.durability -= 1 + if item.durability <= 0: + player.inventory.remove(item) + + def storage_limits(self, player): + # Can't hoard infinite resources + max_storage = 10000 + for resource in player.inventory: + if player.inventory[resource] > max_storage: + player.inventory[resource] = max_storage +``` + +**Resource sinks to add**: +- Consumables (fuel, ammo, food) +- Repairs (items degrade) +- Decay (items expire) +- Storage limits (can't hoard infinitely) +- Crafting failures (chance to lose materials) + +### Pitfall 6: New Player Hyperinflation Trap + +**The Mistake**: +```python +# ❌ New players join into inflated economy +class NewPlayerTrap: + def __init__(self): + # Month 1: Ships cost 3,500 + # Month 6: Ships cost 500,000 (inflation) + # New player has: 10,000 credits + # Can't afford basic items! + pass +``` + +**Why It Fails**: +- Veterans have millions of credits +- Prices inflate to match veteran wealth +- New players can't afford anything +- New player retention plummets + +**Real-World Example**: Eve Online addresses this with "new player areas" where prices are capped and subsidized. + +**The Fix**: +```python +# ✅ Scale starting credits with inflation +class NewPlayerProtection: + def calculate_starting_credits(self): + # Calculate current price index + current_price_level = self.calculate_average_price() + base_price_level = 100 # Launch prices + + inflation_ratio = current_price_level / base_price_level + + # Scale starting credits + base_starting_credits = 10000 + adjusted_credits = base_starting_credits * inflation_ratio + + return adjusted_credits + + def new_player_marketplace(self, player): + # Separate market for new players + if player.account_age_days < 7: + # Access to subsidized prices + return self.new_player_market + else: + return self.main_market +``` + +**Protections to add**: +- Scaling starting credits (track inflation) +- New player zones (capped prices) +- Subsidized vendors (sell basics cheaply) +- Progressive taxation (veterans lose money to system) + +### Pitfall 7: Market Death Spirals + +**The Mistake**: +```python +# ❌ Price spikes cause demand collapse +class DeathSpiral: + # Platinum spikes to 500 (10x normal) + # → Nobody buys Platinum + # → Supply accumulates + # → Price crashes to 5 (10x too low) + # → Nobody mines Platinum + # → Supply dries up + # → Price spikes again + # OSCILLATION FOREVER +``` + +**Why It Fails**: +- Unstable equilibrium (no damping) +- Market never settles +- Players can't rely on prices +- Crafting becomes impossible + +**The Fix**: +```python +# ✅ NPC market makers stabilize prices +class StabilizedMarket: + def __init__(self): + self.npc_maker = NPCMarketMaker( + target_price=50, + liquidity=10000 + ) + + def update(self): + if self.is_market_unstable(): + # NPC provides liquidity to stabilize + self.npc_maker.provide_liquidity(self.order_book) +``` + +**Stabilization techniques**: +- NPC market makers (provide liquidity) +- Price floors (NPCs always buy at minimum) +- Supply decay (excess inventory expires) +- Inventory limits (can't stockpile infinitely) + + +## Real-World Examples + +### Example 1: Eve Online (Complex Player-Driven Economy) + +**Eve Online** has one of the most complex game economies, with: +- 300,000+ active players +- $100+ million USD equivalent traded annually +- Full order book matching +- Regional markets (different prices per system) +- Player corporations (guilds with shared resources) + +**Key Patterns**: + +```python +# Conceptual Eve economy system +class EveOnlineEconomy: + def __init__(self): + # Regional markets (different prices per system) + self.regions = {} # {region_id: Market} + + # Full order book matching + self.order_books = {} # {(item, region): OrderBook} + + # NPC sinks + self.npc_seeding = True # NPCs sell blueprints (faucets) + self.transaction_tax = 0.025 # 2.5% sales tax (sink) + self.broker_fee = 0.03 # 3% listing fee (sink) + + def regional_arbitrage(self, item): + '''Hauling creates gameplay (buy cheap, sell expensive)''' + # Example: Minerals cheap in mining systems + jita_price = self.order_books[(item, 'Jita')].get_best_price() + null_sec_price = self.order_books[(item, 'Null-Sec')].get_best_price() + + profit_per_unit = null_sec_price - jita_price + + # Hauling is risky (pirates, travel time) + # But profitable if successful + return profit_per_unit + + def production_chains(self): + '''Deep production chains (10+ steps)''' + # Minerals → Components → Subsystems → Ships + # Each step adds value + # Complex chains require specialization + + # Example: Building a Titan (supercarrier) + # - Requires 100+ different materials + # - Takes 6+ weeks of production time + # - Costs 70+ billion ISK (700 PLEX ≈ $11,000 USD) + + def destruction_as_sink(self): + '''Ships are destroyed in combat (major sink)''' + # When ship explodes, 50% of materials are lost + # Creates constant demand for new ships + # Player-driven conflict = economy engine +``` + +**What Eve Gets Right**: +1. **Strong sinks**: 50% of destroyed ship value removed from game +2. **Regional economies**: Hauling creates gameplay and arbitrage +3. **Deep production chains**: Specialization and interdependence +4. **Player-driven conflict**: PvP creates demand (ships blow up) +5. **Full transparency**: All market data is public (third-party tools) + +**Lessons**: +- Destruction is the ultimate sink (items permanently removed) +- Regional markets create hauling gameplay +- Complex chains encourage specialization +- Transparency builds trust + +### Example 2: Path of Exile (Currency Item Economy) + +**Path of Exile** has no gold. Instead, currency items are: +- Functional (used for crafting) +- Tradeable (player-to-player) +- Self-regulating (supply/demand natural) + +**Key Patterns**: + +```python +# Conceptual PoE currency system +class PathOfExileEconomy: + def __init__(self): + # Currency items are consumable (sinks built-in) + self.currencies = { + 'Chaos Orb': {'function': 'reroll_item', 'drop_rate': 0.001}, + 'Exalted Orb': {'function': 'add_mod', 'drop_rate': 0.0001}, + 'Mirror': {'function': 'duplicate_item', 'drop_rate': 0.000001}, + } + + # No NPC trading (player-driven only) + self.npc_vendors = None # NPCs only sell basic items + + def currency_as_sink(self, player, item): + '''Using currency consumes it (automatic sink)''' + if player.inventory['Chaos Orb'] > 0: + player.inventory['Chaos Orb'] -= 1 # Consumed! + item.reroll_mods() # Item gets random mods + + # This creates natural demand: + # - Players use currency to craft + # - Currency is removed from economy + # - Prices remain stable + + def player_trading(self): + '''No auction house - player negotiation''' + # Trade ratios emerge naturally: + # - 1 Exalted Orb ≈ 150 Chaos Orbs + # - 1 Mirror ≈ 300 Exalted Orbs ≈ 45,000 Chaos Orbs + + # No NPC prices mean: + # - Supply/demand sets prices naturally + # - No arbitrage loops (no NPCs to exploit) + # - Barter economy (currency for currency) +``` + +**What PoE Gets Right**: +1. **Currency is consumable**: Using it removes it from economy (built-in sink) +2. **No NPC trading**: Eliminates arbitrage exploits +3. **Functional currency**: Items have inherent value (not abstract credits) +4. **Player-driven prices**: Natural supply/demand equilibrium +5. **Scarcity tiers**: Common to ultra-rare currencies (progression) + +**Lessons**: +- Make currency consumable (sinks built-in) +- Eliminate NPCs from core trading (no exploits) +- Functional currency has inherent value +- Let players set prices (emergent economy) + +### Example 3: World of Warcraft Auction House + +**WoW** has a hybrid economy: +- Player auction house (peer-to-peer) +- NPC vendors (fixed prices) +- Gold faucets (quests, dailies) +- Gold sinks (repairs, mounts, consumables) + +**Key Patterns**: + +```python +# Conceptual WoW auction house +class WoWAuctionHouse: + def __init__(self): + self.listings = [] # Player listings + self.deposit_fee = 0.05 # 5% to list (sink) + self.auction_cut = 0.05 # 5% when sold (sink) + + def create_listing(self, seller, item, price, duration): + '''Player lists item for sale''' + # Deposit fee (lost even if item doesn't sell) + deposit = price * self.deposit_fee + seller.gold -= deposit # SINK + + listing = { + 'seller': seller, + 'item': item, + 'buyout_price': price, + 'duration': duration + } + self.listings.append(listing) + + def buy_listing(self, buyer, listing): + '''Player buys item''' + price = listing['buyout_price'] + + # Buyer pays full price + buyer.gold -= price + + # Auction house takes cut (SINK) + ah_cut = price * self.auction_cut + + # Seller receives (price - cut) + listing['seller'].gold += (price - ah_cut) + + # Item transferred + buyer.inventory.add(listing['item']) + + def gold_sinks(self, player): + '''Various gold sinks''' + # Repairs (items degrade) + repair_cost = 100 + player.gold -= repair_cost + + # Mounts (one-time purchase) + mount_cost = 5000 + player.gold -= mount_cost + + # Consumables (potions, food) + consumable_cost = 50 + player.gold -= consumable_cost + + # Fast travel + flight_cost = 10 + player.gold -= flight_cost +``` + +**What WoW Gets Right**: +1. **Auction house fees**: 5-10% removed from every trade (major sink) +2. **Repair costs**: Items degrade, require gold to fix +3. **One-time purchases**: Mounts, pets, transmog (large sinks) +4. **Consumables**: Constant demand (potions, food, enchants) +5. **NPC luxury items**: Cosmetics, toys (pure sinks) + +**Lessons**: +- Transaction fees are effective sinks (every trade removes gold) +- Durability/repairs create constant spending +- One-time purchases (mounts) remove large amounts +- Consumables provide perpetual sinks + +### Example 4: Diablo 3 (Failed Economy → Fixed) + +**Diablo 3 at launch**: +- Real Money Auction House (RMAH) +- Item drops balanced around trading +- Players could buy best gear (pay-to-win) + +**What Went Wrong**: +```python +# Diablo 3 original economy (FAILED) +class Diablo3OriginalEconomy: + def __init__(self): + # Real money auction house + self.rmah = AuctionHouse(currency='USD') + + # Item drops nerfed (force players to trade) + self.drop_rate_multiplier = 0.1 # 10x lower drops + + def perverse_incentives(self): + '''Players stop playing, start trading''' + # Best gear comes from RMAH, not gameplay + # Players farm gold → buy gear + # OR: Farm items → sell for $$$ + + # Result: Game becomes job, not fun + # Players quit + +# Diablo 3 fixed economy (SUCCESSFUL) +class Diablo3FixedEconomy: + def __init__(self): + # RMAH removed entirely + self.rmah = None + + # No trading (account-bound loot) + self.trading = False + + # Drop rates massively increased + self.drop_rate_multiplier = 10.0 # 10x higher + + def loot_as_reward(self): + '''Playing is rewarding (not trading)''' + # Best gear comes from playing + # No economy, no inflation, no exploits + # Pure game balance +``` + +**Lessons from Diablo 3**: +1. **Real-money trading is toxic**: Creates pay-to-win, farming bots +2. **Removing economy can work**: Account-bound loot eliminates exploits +3. **Don't force trading**: Let players opt-in to economy +4. **Gameplay should reward players**: Not trading/grinding + +### Example 5: Albion Online (Full Loot PvP Economy) + +**Albion Online** economy: +- Full loot PvP (killed players drop everything) +- Player-crafted gear (no NPC vendors) +- Localized resources (encourages regional markets) + +```python +# Conceptual Albion Online economy +class AlbionOnlineEconomy: + def __init__(self): + # All gear is player-crafted + self.npc_vendors = None + + # Full loot PvP (major sink) + self.full_loot = True + + def death_as_sink(self, killed_player): + '''Player death removes items from economy''' + # Killed player drops ALL gear + # 50% is destroyed, 50% is loot + for item in killed_player.equipment: + if random() < 0.5: + # Destroyed (SINK) + item.delete() + else: + # Dropped (loot for killer) + killed_player.position.spawn_loot(item) + + # This creates constant demand for new gear + # Players must re-equip after death + # Crafters always have customers + + def localized_resources(self): + '''Different zones have different resources''' + # Tier 8 resources only in dangerous zones + # Forces risk vs reward decisions + # Creates regional markets (hauling gameplay) +``` + +**What Albion Gets Right**: +1. **Full loot PvP**: Massive item sink (gear destroyed on death) +2. **Player-crafted economy**: No NPC vendors (pure player-driven) +3. **Localized resources**: Regional markets and hauling +4. **Risk vs reward**: Dangerous zones have best resources + +**Lessons**: +- Destruction (death) is powerful sink +- Player-crafting creates interdependence +- Regional resources create hauling gameplay + + +## Cross-References + +### Use This Skill WITH: +- **game-balance/economy-balancing**: Overall game economy (XP, rewards, progression) +- **multiplayer-netcode**: Synchronizing economic state across clients +- **database-design**: Storing transactions, inventories, market data +- **anti-cheat**: Preventing duping, botting, and exploits + +### Use This Skill AFTER: +- **game-design-fundamentals**: Understanding core loops and player motivations +- **progression-systems**: Balancing rewards with economic constraints +- **systems-thinking**: Understanding feedback loops and equilibrium + +### Related Skills: +- **crafting-systems**: Production chains and recipes +- **trading-ui-patterns**: Interface for player marketplaces +- **auction-house-algorithms**: Order matching and price discovery + + +## Testing Checklist + +### Pre-Launch Validation +- [ ] **Faucet/Sink Balance**: Sink/Faucet ratio is 0.8-1.2 +- [ ] **No Arbitrage**: All production chains are net-negative vs NPCs +- [ ] **Price Bounds**: All items have min/max prices set +- [ ] **Bid-Ask Spread**: NPCs buy at 50-70% of sell price +- [ ] **Production Balance**: All chains within 3x profit/hour of each other +- [ ] **Transaction Fees**: 1-5% fee on all trades (major sink) +- [ ] **Consumables**: Items that require constant spending exist +- [ ] **Repair Costs**: Items degrade and require gold/resources to fix +- [ ] **New Player Protection**: Starting credits scale with inflation OR subsidized market +- [ ] **Rate Limiting**: Daily caps on mining/trading to prevent bots + +### Stability Testing +- [ ] **30-Day Simulation**: Simulate 1,000 players for 30 days, verify stable +- [ ] **Exploit Search**: Run exploit detector to find arbitrage loops +- [ ] **Market Manipulation**: Test coordinated buying (cornering) +- [ ] **Bot Resistance**: Verify daily limits prevent 24/7 farming +- [ ] **Death Spiral**: Check if price spikes cause permanent instability + +### Post-Launch Monitoring +- [ ] **Track Money Supply**: Log total credits in economy daily +- [ ] **Track Inflation**: Monitor price index over time +- [ ] **Detect Exploits**: Alert if player earns credits too fast +- [ ] **Monitor Imbalances**: Flag if one production chain dominates +- [ ] **New Player Metrics**: Track if new players can afford basics + +### Emergency Fixes (If Economy Breaks) +- [ ] **Rollback Database**: Restore to before exploit was discovered +- [ ] **Patch Exploit**: Fix infinite money loop immediately +- [ ] **Emergency Sinks**: Add temporary high-cost NPC items +- [ ] **Ban Exploiters**: Remove profits from players who exploited +- [ ] **Communication**: Announce fixes to player base transparently + + +## Summary + +Economic simulation in games requires balancing **faucets** (money entering) and **sinks** (money leaving), validating production chains for **arbitrage exploits**, and implementing **price stabilization** to prevent wild swings. The core principles are: + +1. **Faucets < Sinks**: Money entering must be less than money leaving (0.8-1.0 ratio) +2. **Validate chains**: Production chains must be net-negative vs NPCs (no infinite money) +3. **Price bounds**: Set min/max prices (prevent 0 or infinity) +4. **Bid-ask spread**: NPCs buy at 50-70% of sell price (forces player trading) +5. **Balance chains**: All production chains should have similar profit/hour (within 3x) +6. **Resource sinks**: Consumables, repairs, decay (control velocity) +7. **New player protection**: Scale starting credits with inflation OR subsidized markets +8. **Test before launch**: Run 30-day simulation, exploit detection, balance analysis + +Master these patterns and avoid the common pitfalls (no sinks, NPC arbitrage, unbounded prices, unbalanced chains), and your economy will be stable, engaging, and exploit-resistant for months or years. diff --git a/skills/using-simulation-tactics/ecosystem-simulation.md b/skills/using-simulation-tactics/ecosystem-simulation.md new file mode 100644 index 0000000..1ee43d9 --- /dev/null +++ b/skills/using-simulation-tactics/ecosystem-simulation.md @@ -0,0 +1,1912 @@ + +# Ecosystem Simulation + +## Description +Master predator-prey dynamics, food chains, population control, resource cycling, and extinction prevention. Implement stable ecosystems using Lotka-Volterra equations, carrying capacity, logistic growth, and agent-based models. Balance realism with gameplay, prevent ecosystem collapse, and create engaging survival game mechanics where populations oscillate naturally without extinction or runaway growth. + +## When to Use This Skill +Use this skill when implementing or debugging: +- Survival games with hunting/gathering (deer, wolves, fish, birds) +- Farming simulations with crop growth and pests +- Resource management games (forests, ore regeneration) +- Dynamic wildlife systems (animal populations change over time) +- Food chain mechanics (herbivores, carnivores, apex predators) +- Environmental storytelling (ecosystem collapse as narrative device) + +Do NOT use this skill for: +- Static spawning (enemies appear at fixed rate regardless of population) +- Simple resource regeneration (trees respawn on timer) +- Single-species systems (just zombies, no food chain) +- Non-interactive wildlife (decorative animals that don't eat/reproduce) + + +## Quick Start (Time-Constrained Implementation) + +If you need a working ecosystem quickly (< 4 hours), follow this priority order: + +**CRITICAL (Never Skip)**: +1. **Use Lotka-Volterra differential equations** for predator-prey dynamics +2. **Implement carrying capacity** for all species (grass, prey, predators) +3. **Add extinction prevention** (auto-spawn if population < threshold) +4. **Test stability** for 10+ minutes of game time + +**IMPORTANT (Strongly Recommended)**: +5. Use discrete time steps (1 tick = 1 second or 1 game hour) +6. Add reproduction delays (gestation period prevents instant births) +7. Implement energy budgets (eating provides energy, reproducing costs energy) +8. Debug visualization (plot population graphs over time) + +**CAN DEFER** (Optimize Later): +- Agent-based simulation (start with equation-based) +- Spatial distribution (animals move around map) +- Seasonal effects (winter slows growth) +- Disease/starvation mechanics + +**Example - Stable Ecosystem in 30 Minutes**: +```python +import math + +# Lotka-Volterra parameters (TUNED for stability) +PREY_GROWTH_RATE = 0.1 # α: Prey reproduction rate +PREDATION_RATE = 0.002 # β: Predator efficiency +PREDATOR_GAIN = 0.001 # δ: Predator reproduction from prey +PREDATOR_DEATH_RATE = 0.05 # γ: Predator death rate + +# Carrying capacities (CRITICAL for stability) +GRASS_CAPACITY = 10000 +PREY_CAPACITY = 500 +PREDATOR_CAPACITY = 100 + +# Extinction prevention (CRITICAL) +MIN_PREY = 5 +MIN_PREDATORS = 2 + +def simulate_ecosystem(dt=0.1): + """ + Lotka-Volterra with carrying capacity and extinction prevention + dt: Time step (smaller = more stable, but slower) + """ + grass = 5000 + prey = 100 + predators = 20 + + for tick in range(1000): # 100 seconds of game time + # Logistic growth for grass (carrying capacity) + grass_growth = 50 * (1 - grass / GRASS_CAPACITY) + grass += grass_growth * dt + grass = max(0, min(grass, GRASS_CAPACITY)) + + # Prey dynamics (Lotka-Volterra with carrying capacity) + prey_birth = PREY_GROWTH_RATE * prey * (1 - prey / PREY_CAPACITY) + prey_death = PREDATION_RATE * prey * predators + prey += (prey_birth - prey_death) * dt + + # Predator dynamics (Lotka-Volterra) + predator_birth = PREDATOR_GAIN * prey * predators + predator_death = PREDATOR_DEATH_RATE * predators + predators += (predator_birth - predator_death) * dt + + # Extinction prevention (CRITICAL) + if prey < MIN_PREY: + prey = MIN_PREY + if predators < MIN_PREDATORS: + predators = MIN_PREDATORS + + # Cap populations + prey = min(prey, PREY_CAPACITY) + predators = min(predators, PREDATOR_CAPACITY) + + if tick % 100 == 0: + print(f"Tick {tick}: Grass={grass:.0f}, Prey={prey:.0f}, Predators={predators:.0f}") + + return grass, prey, predators + +# Run simulation +simulate_ecosystem() +``` + +**This gives you:** +- Stable populations that oscillate naturally +- No extinction (prevention kicks in) +- No runaway growth (carrying capacity limits) +- Tunable parameters (adjust α, β, δ, γ for different dynamics) + +**Output Example:** +``` +Tick 0: Grass=5000, Prey=100, Predators=20 +Tick 100: Grass=7200, Prey=85, Predators=22 +Tick 200: Grass=6800, Prey=95, Predators=18 +Tick 300: Grass=7100, Prey=90, Predators=20 +... (continues stably) +``` + + +## Core Concepts + +### 1. Lotka-Volterra Equations (Foundation) + +**What:** Mathematical model of predator-prey dynamics discovered in 1920s. Describes how populations naturally oscillate. + +**The Equations:** +``` +Prey growth: dP/dt = αP - βPQ + - αP: Prey births (proportional to prey population) + - βPQ: Prey deaths (proportional to prey × predators) + +Predator growth: dQ/dt = δβPQ - γQ + - δβPQ: Predator births (from eating prey) + - γQ: Predator deaths (natural mortality) +``` + +**Parameters:** +- **α (alpha)**: Prey birth rate (e.g., 0.1 = 10% growth per time unit) +- **β (beta)**: Predation efficiency (how often predator catches prey) +- **δ (delta)**: Conversion efficiency (prey eaten → predator births) +- **γ (gamma)**: Predator death rate (starvation, old age) + +**Python Implementation:** +```python +def lotka_volterra_step(prey, predators, dt=0.1): + """ + One step of Lotka-Volterra simulation + Returns new (prey, predators) populations + """ + # Parameters (THESE NEED TUNING) + alpha = 0.1 # Prey growth rate + beta = 0.002 # Predation rate + delta = 0.001 # Predator efficiency + gamma = 0.05 # Predator death rate + + # Calculate changes + prey_change = alpha * prey - beta * prey * predators + predator_change = delta * beta * prey * predators - gamma * predators + + # Apply changes + prey += prey_change * dt + predators += predator_change * dt + + # Prevent negative populations + prey = max(0, prey) + predators = max(0, predators) + + return prey, predators + +# Example usage +prey, predators = 100, 20 +for _ in range(1000): + prey, predators = lotka_volterra_step(prey, predators) +``` + +**Key Insight:** Pure Lotka-Volterra creates **perpetual oscillations** (not damped). Populations cycle forever: more prey → more predators → fewer prey → fewer predators → repeat. + +**Problem with Pure L-V:** In games, this creates: +- Wild swings (10 deer → 200 deer → 5 deer → ...) +- Possible extinction (if swing goes to 0) +- No equilibrium (never settles) + +**Solution:** Add **carrying capacity** (see next section). + + +### 2. Carrying Capacity (Prevents Runaway Growth) + +**What:** Maximum population an environment can support. Limits exponential growth. + +**Why Essential:** +- Pure Lotka-Volterra allows infinite prey growth when predators are low +- Real ecosystems have resource limits (food, space, water) +- Prevents 10,000 deer spawning and crashing your game + +**Logistic Growth Formula:** +``` +dP/dt = rP(1 - P/K) + - r: Intrinsic growth rate + - P: Current population + - K: Carrying capacity + - (1 - P/K): Slows growth as P approaches K +``` + +**Behavior:** +- When P << K: Growth ≈ rP (exponential) +- When P ≈ K: Growth ≈ 0 (stabilizes) +- When P > K: Growth < 0 (population decreases) + +**Implementation:** +```python +def logistic_growth(population, growth_rate, carrying_capacity, dt=1.0): + """ + Logistic growth with carrying capacity + """ + growth = growth_rate * population * (1 - population / carrying_capacity) + population += growth * dt + return max(0, min(population, carrying_capacity)) + +# Example: Grass growth +grass = 1000 +GRASS_GROWTH_RATE = 50 # units per time +GRASS_CAPACITY = 10000 + +for tick in range(100): + grass = logistic_growth(grass, GRASS_GROWTH_RATE / GRASS_CAPACITY, + GRASS_CAPACITY, dt=1.0) + print(f"Tick {tick}: Grass = {grass:.0f}") +``` + +**Carrying Capacity for Multi-Tier Food Chain:** +```python +# Grass: Environmental carrying capacity +GRASS_CAPACITY = 10000 + +# Herbivores: Limited by grass +# Rule of thumb: 1 deer needs 100 grass +DEER_CAPACITY = GRASS_CAPACITY / 100 # = 100 deer max + +# Carnivores: Limited by herbivores +# Rule of thumb: 1 wolf needs 5 deer +WOLF_CAPACITY = DEER_CAPACITY / 5 # = 20 wolves max +``` + +**Tuning Carrying Capacity:** +1. Start with high values (avoid constraints) +2. Observe maximum populations that naturally occur +3. Set capacity 20-30% above observed max +4. Adjust if populations hit ceiling too often + + +### 3. Energy Budgets (Realistic Resource Flow) + +**What:** Track energy/hunger for each animal. Eating provides energy, actions consume it. + +**Why:** Prevents unrealistic reproduction (can't reproduce if starving). + +**Energy Flow Model:** +```python +class Animal: + def __init__(self): + self.energy = 100 # Max energy + self.reproduction_threshold = 80 # Need 80+ energy to reproduce + self.starvation_threshold = 10 # Die if < 10 energy + + def eat(self, food_energy): + """Eating provides energy""" + self.energy = min(100, self.energy + food_energy) + + def tick(self, dt): + """Each tick consumes energy""" + self.energy -= 5 * dt # Metabolism + + if self.energy < self.starvation_threshold: + return "starve" # Animal dies + + return "alive" + + def can_reproduce(self): + """Only reproduce if well-fed""" + return self.energy >= self.reproduction_threshold + + def reproduce(self): + """Reproduction costs energy""" + if self.can_reproduce(): + self.energy -= 30 # Cost of birth + return True + return False +``` + +**Energy Values (Rule of Thumb):** +- **Grass → Deer:** 1 grass = 5 energy (inefficient conversion) +- **Deer → Wolf:** 1 deer = 50 energy (meat is energy-dense) +- **Trophic efficiency:** Typically 10% (only 10% of energy passes up food chain) + +**Example with Energy:** +```python +class Deer: + def __init__(self): + self.energy = 50 + self.age = 0 + + def eat_grass(self, grass_amount): + """Deer eats grass, gains energy""" + energy_gained = grass_amount * 5 + self.energy = min(100, self.energy + energy_gained) + return grass_amount # Grass consumed + + def try_reproduce(self): + """Reproduce if energy > 80""" + if self.energy >= 80: + self.energy -= 30 + return Deer() # New baby deer + return None + + def tick(self, dt): + """Daily energy consumption""" + self.energy -= 10 * dt + self.age += dt + + if self.energy <= 0: + return "dead" + return "alive" + +class Wolf: + def __init__(self): + self.energy = 70 + + def eat_deer(self, deer): + """Wolf eats deer, gains energy""" + self.energy = min(100, self.energy + 50) + return True # Deer is eaten + + def try_reproduce(self): + if self.energy >= 85: + self.energy -= 40 + return Wolf() + return None + + def tick(self, dt): + self.energy -= 8 * dt # Wolves burn energy faster + if self.energy <= 0: + return "dead" + return "alive" +``` + +**Key Insight:** Energy budgets create **natural regulation**: +- Low food → animals don't reproduce → population declines +- High food → animals reproduce → population grows +- No manual population caps needed (emerges from energy) + + +### 4. Agent-Based vs Equation-Based Models + +**Two Approaches:** + +#### Equation-Based (Fast, Smooth) +- Treat populations as continuous numbers (100.5 deer) +- Use differential equations (Lotka-Volterra) +- Update all at once (no individual tracking) + +**Pros:** +- Very fast (O(1) per species, not O(N) per animal) +- Smooth behavior (no randomness) +- Easy to tune (adjust α, β, δ, γ parameters) +- Predictable (same starting conditions → same result) + +**Cons:** +- Can't have individual differences (all deer identical) +- No spatial distribution (can't hunt specific deer) +- Less engaging for player (numbers, not animals) +- Fractional animals (23.7 deer?) + +**When to Use:** Large populations (100+ animals), background ecosystem, performance-critical. + +#### Agent-Based (Detailed, Spatial) +- Each animal is an object with position, energy, age +- Animals move, hunt, eat specific food +- Emergent behavior from individual rules + +**Pros:** +- Player can interact with individuals (hunt specific deer) +- Spatial distribution (animals in different areas) +- More realistic (animals have personalities, ages) +- Visually engaging (see animals move) + +**Cons:** +- Slower (O(N) per animal, can be O(N²) for interactions) +- More random (same start → different results) +- Harder to tune (many emergent behaviors) +- Requires spatial partitioning (quadtree, grid) for performance + +**When to Use:** Player-visible animals (< 100), hunting mechanics, spatial gameplay. + +**Hybrid Approach (Recommended):** +```python +# Close to player: Agent-based (detailed) +for deer in visible_deer: + deer.move() + deer.seek_grass() + deer.avoid_wolves() + +# Far from player: Equation-based (fast) +distant_deer_population += GROWTH_RATE * distant_deer_population * dt +``` + +**Example Threshold:** +- Within 100m of player: Agent-based (full simulation) +- 100-500m from player: Simplified agents (less frequent updates) +- Beyond 500m: Equation-based (just population numbers) + + +### 5. Time Steps and Stability + +**Critical:** Time step size (dt) affects simulation stability. + +**Euler's Method (Simple but Unstable):** +```python +# Large time step (dt = 1.0) +prey += (alpha * prey - beta * prey * predators) * 1.0 +predators += (delta * beta * prey * predators - gamma * predators) * 1.0 +``` + +**Problem:** If changes are large relative to populations, can overshoot: +- Prey = 10, predators = 50 → prey change = -100 → prey = -90 (negative!) + +**Solution 1: Small Time Steps** +```python +# Smaller dt = more stable (but more iterations needed) +dt = 0.01 # Instead of 1.0 +for _ in range(100): # 100 steps to equal 1.0 time unit + prey += (alpha * prey - beta * prey * predators) * dt + predators += (delta * beta * prey * predators - gamma * predators) * dt +``` + +**Solution 2: Runge-Kutta 4th Order (RK4) - More Accurate** +```python +def rk4_step(prey, predators, dt): + """ + Runge-Kutta 4th order integration (much more stable) + """ + def derivatives(p, q): + dp = alpha * p - beta * p * q + dq = delta * beta * p * q - gamma * q + return dp, dq + + # Calculate slopes + k1_p, k1_q = derivatives(prey, predators) + k2_p, k2_q = derivatives(prey + 0.5 * dt * k1_p, predators + 0.5 * dt * k1_q) + k3_p, k3_q = derivatives(prey + 0.5 * dt * k2_p, predators + 0.5 * dt * k2_q) + k4_p, k4_q = derivatives(prey + dt * k3_p, predators + dt * k3_q) + + # Weighted average + prey += (dt / 6.0) * (k1_p + 2*k2_p + 2*k3_p + k4_p) + predators += (dt / 6.0) * (k1_q + 2*k2_q + 2*k3_q + k4_q) + + return prey, predators +``` + +**Rule of Thumb for Time Step:** +- **dt = 0.01 to 0.1:** Safe for most simulations +- **dt = 1.0:** Only if changes are small relative to populations +- **Adaptive dt:** Reduce dt when changes are large, increase when stable + +**Testing Stability:** +```python +def test_stability(dt): + prey, predators = 100, 20 + for _ in range(10000): + prey, predators = lotka_volterra_step(prey, predators, dt) + if prey < 0 or predators < 0: + return False # Unstable! + if math.isnan(prey) or math.isnan(predators): + return False # Exploded! + return True # Stable + +# Test different dt values +for dt in [1.0, 0.1, 0.01, 0.001]: + stable = test_stability(dt) + print(f"dt={dt}: {'STABLE' if stable else 'UNSTABLE'}") +``` + + +## Decision Frameworks + +### Framework 1: Full Simulation vs Simplified Model + +**Question:** How realistic should the ecosystem be? + +**Factors:** +1. **Player interaction**: Can player hunt individual animals? +2. **Performance budget**: How many animals can you simulate? +3. **Gameplay importance**: Is ecosystem core mechanic or background? +4. **Development time**: Weeks or months available? + +**Decision Tree:** +``` +Q: Does player hunt/interact with individual animals? +├─ YES: Use agent-based (need spatial, individual tracking) +│ └─ Q: More than 100 animals? +│ ├─ YES: Hybrid (agents near player, equations far away) +│ └─ NO: Full agent-based +│ +└─ NO: Use equation-based (just track population numbers) + └─ Q: Need different biomes/regions? + ├─ YES: Multiple equation sets (one per biome) + └─ NO: Single global equation +``` + +**Examples:** + +| Game Mechanic | Approach | Why | +|---------------|----------|-----| +| Background wildlife (Far Cry) | Equation-based | 100+ animals, player rarely interacts | +| Hunting game (The Hunter) | Agent-based | Track specific deer, spatial stalking | +| City builder (SimCity) | Equation-based | Abstract "population", not individuals | +| Survival game (Don't Starve) | Hybrid | Visible animals = agents, distant = numbers | +| Ecosystem collapse narrative | Equation-based | Just need population graphs declining | + +**Complexity Thresholds:** + +| Features | Implementation Time | Approach | +|----------|---------------------|----------| +| Just population numbers | 2-4 hours | Equation-based | +| + Individual tracking | 1-2 days | Agent-based (simple) | +| + Spatial distribution | 3-5 days | Agent-based + quadtree | +| + Complex AI (herding) | 1-2 weeks | Agent-based + steering behaviors | +| + Genetics/evolution | 2-4 weeks | Agent-based + genetic system | + + +### Framework 2: Deterministic vs Stochastic Populations + +**Question:** Should populations have randomness? + +**Deterministic (No Randomness):** +```python +# Always produces same result with same starting conditions +prey += alpha * prey - beta * prey * predators +``` + +**Pros:** +- Predictable: Same start → same result +- Tunable: Easy to find stable parameters +- Reproducible: Can debug exact sequence +- Smooth: No sudden jumps + +**Cons:** +- Boring: Feels mechanical +- Exploitable: Players learn exact patterns +- Unrealistic: Real populations have variance + +**Stochastic (With Randomness):** +```python +# Random variance in births/deaths +prey_births = poisson_random(alpha * prey) # Poisson distribution +prey_deaths = binomial_random(beta * prey * predators) # Binomial +prey += prey_births - prey_deaths +``` + +**Pros:** +- Engaging: Each playthrough different +- Realistic: Matches real population variance +- Unpredictable: Players can't exploit +- Natural: Feels organic + +**Cons:** +- Harder to tune: Parameters vary by random seed +- Can cause extinction: Bad RNG → population dies +- Less smooth: Populations jump around +- Harder to debug: Can't reproduce exact bug + +**Decision Guide:** + +| Game Type | Approach | Randomness Amount | +|-----------|----------|-------------------| +| Puzzle game (requires predictability) | Deterministic | 0% | +| Strategy game (needs planning) | Mostly deterministic | 5-10% variance | +| Survival game (replayability) | Balanced stochastic | 20-30% variance | +| Roguelike (each run unique) | Highly stochastic | 40-50% variance | + +**Balanced Approach (Recommended):** +```python +def balanced_reproduction(base_births, variance=0.2): + """ + Deterministic core with controlled randomness + variance: 0.2 = ±20% random variation + """ + random_factor = random.uniform(1 - variance, 1 + variance) + return base_births * random_factor + +# Example +base_deer_births = alpha * deer_population +actual_births = balanced_reproduction(base_deer_births, variance=0.15) +deer_population += actual_births +``` + +**Red Flag:** Variance > 50% creates chaotic, untunable systems. + + +### Framework 3: When to Intervene (Preventing Collapse) + +**Question:** Should you let ecosystems collapse naturally or intervene? + +**Philosophy:** + +**1. Simulation Purist** (Let Nature Run Its Course) +- No intervention: If all deer die, they die +- Teaches player consequences: Overhunting → extinction +- Narrative potential: Ecosystem collapse as story beat +- Risk: Permanent ecosystem failure, unwinnable state + +**2. Gameplay Pragmatist** (Prevent Unfun Outcomes) +- Auto-balance: Respawn animals if population too low +- Soft boundaries: Migration brings new animals +- Invisible hand: Adjust parameters dynamically +- Risk: Feels artificial, reduces player agency + +**Decision Framework:** + +``` +Q: Is ecosystem core gameplay mechanic? +├─ YES (survival game, ecosystem manager) +│ └─ Q: Should player failure end game? +│ ├─ YES: Allow collapse (but warn player!) +│ │ - Show "Deer population critical!" warnings +│ │ - Provide recovery mechanisms (reintroduction) +│ │ - Make collapse recoverable (not instant death) +│ │ +│ └─ NO: Soft intervention +│ - Auto-spawn if population < 5 (migration) +│ - Slow recovery (not instant fix) +│ - Player notices but it's not jarring +│ +└─ NO (background wildlife) + └─ Always intervene (prevent collapse) + - Player shouldn't notice ecosystem management + - Just ensure world feels alive +``` + +**Intervention Techniques:** + +**Technique 1: Extinction Prevention (Invisible)** +```python +MIN_POPULATION = 5 + +if deer < MIN_POPULATION: + deer = MIN_POPULATION # Instant fix + # Justification: "Migration from neighboring territory" +``` + +**Pros:** Simple, effective, invisible +**Cons:** Can feel artificial if player notices + +**Technique 2: Slow Recovery (Visible)** +```python +MIN_POPULATION = 5 +RECOVERY_RATE = 1.0 # 1 animal per time unit + +if deer < MIN_POPULATION: + deer += RECOVERY_RATE * dt + show_notification("Deer migrating into area") +``` + +**Pros:** Feels natural, player sees recovery +**Cons:** Slower, player might notice pattern + +**Technique 3: Dynamic Parameter Tuning** +```python +# Adjust predation rate based on prey population +if deer < 50: + # Reduce predation when prey is low + effective_beta = beta * 0.5 +else: + effective_beta = beta + +predation = effective_beta * prey * predators +``` + +**Pros:** Soft, invisible, maintains balance +**Cons:** Hard to tune, can feel arbitrary + +**Technique 4: Reintroduction (Player Action)** +```python +# Player can manually reintroduce species +if deer == 0: + show_quest("Wildlife Crisis: Reintroduce Deer") + # Player must travel to neighboring area, bring back breeding pair + if player_completes_quest(): + deer = 10 # Player action, feels earned +``` + +**Pros:** Player agency, narrative potential +**Cons:** Requires quest system, can interrupt gameplay + +**Red Flags for Intervention:** +- ❌ Instant population resets (deer: 0 → 100 in one tick) - Jarring +- ❌ Obvious patterns (deer always spawn at exactly 5) - Exploitable +- ❌ No player feedback (population mysteriously stable) - Confusing +- ✅ Gradual recovery (deer: 5 → 10 → 20 over 5 minutes) - Natural +- ✅ Contextual (migration event, seasonal breeding) - Believable + + +## Implementation Patterns + +### Pattern 1: Lotka-Volterra with Carrying Capacity (Stable Ecosystem) + +**Complete, production-ready implementation:** + +```python +import math +import matplotlib.pyplot as plt # For visualization + +class EcosystemSimulation: + def __init__(self): + # Lotka-Volterra parameters (TUNED for stability) + self.prey_growth_rate = 0.1 # α: Prey birth rate + self.predation_rate = 0.002 # β: Predation efficiency + self.predator_gain = 0.001 # δ: Predator birth from prey + self.predator_death_rate = 0.05 # γ: Predator death rate + + # Carrying capacities + self.grass_capacity = 10000 + self.prey_capacity = 500 + self.predator_capacity = 100 + + # Extinction prevention + self.min_prey = 5 + self.min_predators = 2 + + # Initial populations + self.grass = 5000 + self.prey = 100 + self.predators = 20 + + # History for plotting + self.history = { + 'time': [], + 'grass': [], + 'prey': [], + 'predators': [] + } + + def step(self, dt=0.1): + """ + One simulation step using Lotka-Volterra with modifications + dt: Time step size (0.1 recommended for stability) + """ + # 1. Grass growth (logistic growth with carrying capacity) + grass_growth = 50 * (1 - self.grass / self.grass_capacity) + self.grass += grass_growth * dt + self.grass = max(0, min(self.grass, self.grass_capacity)) + + # 2. Prey consumption of grass (limits prey growth) + grass_eaten = min(self.grass, self.prey * 10 * dt) + self.grass -= grass_eaten + prey_fed_ratio = grass_eaten / (self.prey * 10 * dt) if self.prey > 0 else 0 + + # 3. Prey dynamics (Lotka-Volterra with carrying capacity) + prey_birth = self.prey_growth_rate * self.prey * (1 - self.prey / self.prey_capacity) * prey_fed_ratio + prey_death = self.predation_rate * self.prey * self.predators + self.prey += (prey_birth - prey_death) * dt + + # 4. Predator dynamics (Lotka-Volterra) + predator_birth = self.predator_gain * self.prey * self.predators + predator_death = self.predator_death_rate * self.predators + self.predators += (predator_birth - predator_death) * dt + + # 5. Extinction prevention (soft boundaries) + if self.prey < self.min_prey: + self.prey += (self.min_prey - self.prey) * 0.1 * dt # Gradual recovery + if self.predators < self.min_predators: + self.predators += (self.min_predators - self.predators) * 0.1 * dt + + # 6. Cap populations at carrying capacity + self.prey = min(self.prey, self.prey_capacity) + self.predators = min(self.predators, self.predator_capacity) + + # Ensure non-negative + self.grass = max(0, self.grass) + self.prey = max(0, self.prey) + self.predators = max(0, self.predators) + + def run(self, duration=100, dt=0.1): + """ + Run simulation for specified duration + duration: Total game time to simulate + dt: Time step size + """ + time = 0 + while time < duration: + self.step(dt) + + # Record history + self.history['time'].append(time) + self.history['grass'].append(self.grass) + self.history['prey'].append(self.prey) + self.history['predators'].append(self.predators) + + time += dt + + def plot(self): + """Visualize population dynamics""" + plt.figure(figsize=(12, 6)) + plt.plot(self.history['time'], self.history['grass'], label='Grass', alpha=0.7) + plt.plot(self.history['time'], self.history['prey'], label='Prey (Deer)', alpha=0.7) + plt.plot(self.history['time'], self.history['predators'], label='Predators (Wolves)', alpha=0.7) + plt.xlabel('Time') + plt.ylabel('Population') + plt.title('Ecosystem Population Dynamics') + plt.legend() + plt.grid(True, alpha=0.3) + plt.show() + + def check_stability(self): + """ + Analyze last 100 samples for stability + Returns True if ecosystem is stable (small oscillations) + """ + if len(self.history['time']) < 100: + return False + + recent_prey = self.history['prey'][-100:] + recent_predators = self.history['predators'][-100:] + + prey_variance = max(recent_prey) - min(recent_prey) + predator_variance = max(recent_predators) - min(recent_predators) + + # Stable if variance is < 30% of mean + prey_mean = sum(recent_prey) / len(recent_prey) + predator_mean = sum(recent_predators) / len(recent_predators) + + prey_stable = prey_variance < 0.3 * prey_mean + predator_stable = predator_variance < 0.3 * predator_mean + + return prey_stable and predator_stable + +# Usage +sim = EcosystemSimulation() +sim.run(duration=100, dt=0.1) + +print(f"Final populations:") +print(f" Grass: {sim.grass:.0f}") +print(f" Prey: {sim.prey:.0f}") +print(f" Predators: {sim.predators:.0f}") +print(f"Ecosystem stable: {sim.check_stability()}") + +# Visualize (requires matplotlib) +# sim.plot() +``` + +**Key Features:** +- ✅ Lotka-Volterra foundation (natural oscillations) +- ✅ Carrying capacity (prevents runaway growth) +- ✅ Grass depletion (prey can't grow infinitely) +- ✅ Extinction prevention (gradual recovery) +- ✅ Stability analysis (check if tuned correctly) +- ✅ Visualization (debug population dynamics) + +**Tuning Parameters:** +1. Run simulation for 100 time units +2. Plot populations (use `sim.plot()`) +3. If oscillations too wild: Reduce `prey_growth_rate` or increase `predation_rate` +4. If predators die out: Increase `predator_gain` or reduce `predator_death_rate` +5. If prey die out: Reduce `predation_rate` or increase `prey_growth_rate` +6. Target: Oscillations of ±20% around equilibrium + + +### Pattern 2: Agent-Based Simulation (Spatial Ecosystem) + +**When:** Player hunts individual animals, need spatial distribution. + +```python +import random +import math + +class Animal: + def __init__(self, x, y, species): + self.x = x + self.y = y + self.species = species + self.energy = 100 + self.age = 0 + self.alive = True + + def distance_to(self, other): + """Calculate distance to another entity""" + return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2) + + def move_toward(self, target_x, target_y, speed): + """Move toward target position""" + dx = target_x - self.x + dy = target_y - self.y + dist = math.sqrt(dx**2 + dy**2) + + if dist > 0: + self.x += (dx / dist) * speed + self.y += (dy / dist) * speed + + def move_away(self, target_x, target_y, speed): + """Move away from target position""" + dx = self.x - target_x + dy = self.y - target_y + dist = math.sqrt(dx**2 + dy**2) + + if dist > 0: + self.x += (dx / dist) * speed + self.y += (dy / dist) * speed + + def random_wander(self, speed): + """Random movement""" + angle = random.uniform(0, 2 * math.pi) + self.x += math.cos(angle) * speed + self.y += math.sin(angle) * speed + +class Grass: + def __init__(self, x, y): + self.x = x + self.y = y + self.amount = 100 # Grass density + +class Deer(Animal): + def __init__(self, x, y): + super().__init__(x, y, "deer") + self.energy = 50 + self.reproduction_cooldown = 0 + + def update(self, dt, grass_patches, wolves): + """ + Deer behavior: + 1. Flee from nearby wolves + 2. Seek nearby grass + 3. Wander if nothing to do + """ + self.age += dt + self.energy -= 3 * dt # Metabolism + self.reproduction_cooldown = max(0, self.reproduction_cooldown - dt) + + # Check if starving + if self.energy <= 0: + self.alive = False + return None + + # 1. Flee from wolves (highest priority) + nearest_wolf = None + min_wolf_dist = float('inf') + for wolf in wolves: + if not wolf.alive: + continue + dist = self.distance_to(wolf) + if dist < 30 and dist < min_wolf_dist: + min_wolf_dist = dist + nearest_wolf = wolf + + if nearest_wolf: + # Flee! + self.move_away(nearest_wolf.x, nearest_wolf.y, speed=5 * dt) + return None + + # 2. Seek grass (if hungry) + if self.energy < 80: + nearest_grass = None + min_grass_dist = float('inf') + for grass in grass_patches: + if grass.amount < 10: + continue # Skip depleted grass + dist = self.distance_to(grass) + if dist < min_grass_dist: + min_grass_dist = dist + nearest_grass = grass + + if nearest_grass: + if min_grass_dist < 2: + # Eat grass + eaten = min(20, nearest_grass.amount) + nearest_grass.amount -= eaten + self.energy = min(100, self.energy + eaten * 0.5) + else: + # Move toward grass + self.move_toward(nearest_grass.x, nearest_grass.y, speed=3 * dt) + return None + + # 3. Reproduce (if well-fed and cooldown expired) + if self.energy > 80 and self.reproduction_cooldown == 0: + self.energy -= 30 + self.reproduction_cooldown = 20 # 20 time units between births + # Create baby deer nearby + baby = Deer( + self.x + random.uniform(-2, 2), + self.y + random.uniform(-2, 2) + ) + return baby + + # 4. Wander + self.random_wander(speed=2 * dt) + return None + +class Wolf(Animal): + def __init__(self, x, y): + super().__init__(x, y, "wolf") + self.energy = 70 + self.reproduction_cooldown = 0 + + def update(self, dt, deer_list): + """ + Wolf behavior: + 1. Hunt nearby deer + 2. Wander if no prey + """ + self.age += dt + self.energy -= 5 * dt # Wolves burn more energy + self.reproduction_cooldown = max(0, self.reproduction_cooldown - dt) + + # Check if starving + if self.energy <= 0: + self.alive = False + return None + + # 1. Hunt deer + nearest_deer = None + min_deer_dist = float('inf') + for deer in deer_list: + if not deer.alive: + continue + dist = self.distance_to(deer) + if dist < 50 and dist < min_deer_dist: + min_deer_dist = dist + nearest_deer = deer + + if nearest_deer: + if min_deer_dist < 2: + # Catch deer! + nearest_deer.alive = False + self.energy = min(100, self.energy + 50) + + # Reproduce if well-fed + if self.energy > 85 and self.reproduction_cooldown == 0: + self.energy -= 40 + self.reproduction_cooldown = 30 + baby = Wolf( + self.x + random.uniform(-2, 2), + self.y + random.uniform(-2, 2) + ) + return baby + else: + # Chase deer + self.move_toward(nearest_deer.x, nearest_deer.y, speed=4 * dt) + return None + + # 2. Wander + self.random_wander(speed=3 * dt) + return None + +class AgentBasedEcosystem: + def __init__(self, world_size=100): + self.world_size = world_size + self.grass_patches = [] + self.deer = [] + self.wolves = [] + + # Initialize grass patches (grid) + for x in range(0, world_size, 10): + for y in range(0, world_size, 10): + self.grass_patches.append(Grass(x, y)) + + # Initialize deer + for _ in range(50): + self.deer.append(Deer( + random.uniform(0, world_size), + random.uniform(0, world_size) + )) + + # Initialize wolves + for _ in range(10): + self.wolves.append(Wolf( + random.uniform(0, world_size), + random.uniform(0, world_size) + )) + + def step(self, dt=0.1): + """One simulation step""" + + # 1. Grass regrowth + for grass in self.grass_patches: + grass.amount = min(100, grass.amount + 5 * dt) + + # 2. Update deer + new_deer = [] + for deer in self.deer: + if not deer.alive: + continue + baby = deer.update(dt, self.grass_patches, self.wolves) + if baby: + new_deer.append(baby) + + # Remove dead deer + self.deer = [d for d in self.deer if d.alive] + self.deer.extend(new_deer) + + # 3. Update wolves + new_wolves = [] + for wolf in self.wolves: + if not wolf.alive: + continue + baby = wolf.update(dt, self.deer) + if baby: + new_wolves.append(baby) + + # Remove dead wolves + self.wolves = [w for w in self.wolves if w.alive] + self.wolves.extend(new_wolves) + + # 4. Extinction prevention + if len(self.deer) < 5: + # Spawn deer at random locations + for _ in range(5 - len(self.deer)): + self.deer.append(Deer( + random.uniform(0, self.world_size), + random.uniform(0, self.world_size) + )) + + if len(self.wolves) < 2: + for _ in range(2 - len(self.wolves)): + self.wolves.append(Wolf( + random.uniform(0, self.world_size), + random.uniform(0, self.world_size) + )) + + def run(self, steps=1000, dt=0.1): + """Run simulation""" + for i in range(steps): + self.step(dt) + if i % 100 == 0: + print(f"Step {i}: Deer={len(self.deer)}, Wolves={len(self.wolves)}") + +# Usage +ecosystem = AgentBasedEcosystem(world_size=100) +ecosystem.run(steps=1000, dt=0.1) +``` + +**Key Features:** +- ✅ Individual animals with position, energy, behavior +- ✅ Spatial interactions (deer flee from nearby wolves) +- ✅ Emergent herding (deer near grass, wolves chase deer) +- ✅ Reproduction with cooldowns (prevents explosions) +- ✅ Starvation (animals die if energy depletes) +- ✅ Extinction prevention (respawn if too few) + +**Performance Optimization:** +For > 100 animals, add spatial partitioning: +```python +class SpatialGrid: + def __init__(self, world_size, cell_size=10): + self.cell_size = cell_size + self.cells = {} + + def add(self, animal): + cell_x = int(animal.x / self.cell_size) + cell_y = int(animal.y / self.cell_size) + key = (cell_x, cell_y) + if key not in self.cells: + self.cells[key] = [] + self.cells[key].append(animal) + + def get_nearby(self, x, y, radius): + """Get animals within radius of (x, y)""" + nearby = [] + cell_x = int(x / self.cell_size) + cell_y = int(y / self.cell_size) + cell_radius = int(radius / self.cell_size) + 1 + + for dx in range(-cell_radius, cell_radius + 1): + for dy in range(-cell_radius, cell_radius + 1): + key = (cell_x + dx, cell_y + dy) + if key in self.cells: + nearby.extend(self.cells[key]) + + return nearby +``` + +This reduces neighbor search from O(N²) to O(N). + + +### Pattern 3: Hybrid Approach (LOD System) + +**Best of Both Worlds:** Agents near player, equations far away. + +```python +class HybridEcosystem: + def __init__(self): + # Agent-based (near player) + self.visible_deer = [] + self.visible_wolves = [] + + # Equation-based (distant) + self.distant_deer_population = 200 + self.distant_wolf_population = 40 + + # Parameters + self.visibility_radius = 100 + self.player_x = 0 + self.player_y = 0 + + def update_player_position(self, x, y): + """Player moved, update what's visible""" + self.player_x = x + self.player_y = y + + # Check if distant animals should become visible + self.spawn_visible_animals() + + # Check if visible animals moved too far + self.despawn_distant_animals() + + def spawn_visible_animals(self): + """Convert distant population to visible agents""" + # Probability based on distant population + spawn_chance = self.distant_deer_population / 1000.0 + + if random.random() < spawn_chance: + # Spawn deer at edge of visibility + angle = random.uniform(0, 2 * math.pi) + x = self.player_x + math.cos(angle) * self.visibility_radius + y = self.player_y + math.sin(angle) * self.visibility_radius + self.visible_deer.append(Deer(x, y)) + self.distant_deer_population -= 1 + + def despawn_distant_animals(self): + """Convert visible agents back to distant population""" + for deer in self.visible_deer[:]: + dist = math.sqrt((deer.x - self.player_x)**2 + (deer.y - self.player_y)**2) + if dist > self.visibility_radius * 1.5: + self.visible_deer.remove(deer) + if deer.alive: + self.distant_deer_population += 1 + + def step(self, dt=0.1): + """Hybrid update""" + # 1. Update visible agents (agent-based) + for deer in self.visible_deer: + deer.update(dt, grass_patches=[], wolves=self.visible_wolves) + + for wolf in self.visible_wolves: + wolf.update(dt, deer_list=self.visible_deer) + + # 2. Update distant populations (equation-based) + # Lotka-Volterra for distant populations + alpha = 0.1 + beta = 0.002 + delta = 0.001 + gamma = 0.05 + + prey_change = alpha * self.distant_deer_population - beta * self.distant_deer_population * self.distant_wolf_population + predator_change = delta * beta * self.distant_deer_population * self.distant_wolf_population - gamma * self.distant_wolf_population + + self.distant_deer_population += prey_change * dt + self.distant_wolf_population += predator_change * dt + + # Cap and prevent negatives + self.distant_deer_population = max(5, min(500, self.distant_deer_population)) + self.distant_wolf_population = max(2, min(100, self.distant_wolf_population)) +``` + +**When to Use:** +- Open world games (Skyrim, Far Cry) +- Large maps with 500+ animals +- Player can hunt but can't see all at once +- Performance budget limited + +**Benefits:** +- Fast (equations handle 90% of population) +- Immersive (player sees/interacts with individuals) +- Scalable (can have 1000s of "distant" animals) + + +## Common Pitfalls + +### Pitfall 1: Ecosystem Collapse Within Minutes + +**The Mistake:** +```python +# ❌ No carrying capacity, no extinction prevention +prey += alpha * prey - beta * prey * predators +predators += delta * beta * prey * predators - gamma * predators + +# Result: Prey goes to 0 within 5 ticks, predators starve +``` + +**Why This Fails:** +- Pure Lotka-Volterra allows extinction (prey → 0) +- No recovery mechanism +- Predators overfish prey instantly + +**Real-World Example:** +Player starts game, hunts 20 deer in first 10 minutes. Deer population: 100 → 80. Without recovery, wolves eat remaining 80 deer. Deer extinct. Wolves starve. 30 minutes in, world is dead. + +**The Fix:** +```python +# ✅ Extinction prevention + carrying capacity +MIN_PREY = 10 +PREY_CAPACITY = 500 + +prey += alpha * prey * (1 - prey / PREY_CAPACITY) - beta * prey * predators + +if prey < MIN_PREY: + prey += (MIN_PREY - prey) * 0.1 * dt # Gradual recovery + show_notification("Deer migrating from neighboring forest") +``` + +**Testing:** +```python +def test_no_extinction(): + prey, predators = 100, 50 + for _ in range(10000): + prey, predators = simulate_step(prey, predators) + assert prey >= MIN_PREY, f"Extinction! Prey={prey}" + assert predators >= MIN_PREDATORS, f"Extinction! Predators={predators}" +``` + + +### Pitfall 2: Runaway Population Explosion + +**The Mistake:** +```python +# ❌ No cap on population, exponential growth +for deer in deer_list: + if deer.energy > 80: + deer_list.append(Deer()) # Infinite growth! +``` + +**Result:** +- Tick 0: 50 deer +- Tick 10: 200 deer +- Tick 20: 2,000 deer +- Tick 30: 50,000 deer (game crashes) + +**Why This Fails:** +- No carrying capacity +- No resource limits (grass infinite) +- Reproduction has no cost + +**The Fix:** +```python +# ✅ Carrying capacity + resource limits +PREY_CAPACITY = 500 + +for deer in deer_list[:]: # Copy list to avoid mutation during iteration + if deer.energy > 80 and len(deer_list) < PREY_CAPACITY: + # Reproduce only if below carrying capacity + deer.energy -= 30 # Reproduction cost + deer_list.append(Deer()) +``` + + +### Pitfall 3: Too Chaotic (No Stable Equilibrium) + +**The Mistake:** +```python +# ❌ Too much randomness, no damping +births = random.uniform(0, alpha * prey * 2) # 0-200% variance! +deaths = random.uniform(0, beta * prey * predators * 2) +prey += births - deaths +``` + +**Result:** +- Tick 0: 100 deer +- Tick 1: 150 deer (random spike) +- Tick 2: 30 deer (random crash) +- Tick 3: 200 deer (random spike) +- Never settles into stable oscillation + +**Why This Fails:** +- Variance > 50% creates chaos +- No negative feedback (crashes beget more crashes) +- Can't tune (every run different) + +**The Fix:** +```python +# ✅ Controlled randomness (±15% max) +base_births = alpha * prey +actual_births = base_births * random.uniform(0.85, 1.15) # ±15% + +# Or use deterministic core with stochastic sampling +if random.random() < (alpha * prey - int(alpha * prey)): + births = int(alpha * prey) + 1 +else: + births = int(alpha * prey) +``` + +**Red Flag:** If population graph looks like random noise (not smooth oscillations), reduce randomness. + + +### Pitfall 4: No Understanding of Lotka-Volterra Theory + +**The Mistake:** +```python +# ❌ Made-up rules with no ecological foundation +prey += 10 # Prey grows by constant 10 +if predators > prey: + prey -= 5 # Arbitrary rule +if wolves_are_hungry: + wolves += 2 # Another arbitrary rule +``` + +**Why This Fails:** +- No basis in population dynamics theory +- Rules don't create natural oscillations +- Impossible to tune (no parameters to adjust) + +**Real Example:** Agent tries to create "balanced" ecosystem by adding random rules: +- "If deer > 100, deer -= 10" +- "If wolves < 10, wolves += 5" +- "If grass < 500, grass = 1000" + +Result: Feels mechanical, artificial. Populations jump around with no natural flow. + +**The Fix:** Learn and apply Lotka-Volterra: +```python +# ✅ Based on proven ecological theory +# Prey equation: dP/dt = αP - βPQ +prey_change = alpha * prey - beta * prey * predators + +# Predator equation: dQ/dt = δβPQ - γQ +predator_change = delta * beta * prey * predators - gamma * predators + +prey += prey_change * dt +predators += predator_change * dt +``` + +**Key Insight:** Lotka-Volterra creates **natural oscillations** without manual intervention. Prey increases → Predators increase (more food) → Prey decreases (overpredation) → Predators decrease (starvation) → Prey recovers → cycle repeats. + + +### Pitfall 5: Instant Reproduction (No Time Delays) + +**The Mistake:** +```python +# ❌ Deer reproduces immediately after eating +deer.eat(grass) +if deer.energy > 80: + deer_list.append(Deer()) # Instant baby! +``` + +**Result:** +- Deer eats → energy 100 → spawns baby → baby eats → spawns baby → ... +- Population doubles every tick (exponential explosion) + +**Why This Fails:** +- Real animals have gestation periods (months) +- No biological delay +- Positive feedback loop (more deer → more births → more deer) + +**The Fix:** +```python +# ✅ Reproduction cooldown (gestation + maturation) +class Deer: + def __init__(self): + self.energy = 50 + self.reproduction_cooldown = 0 + self.age = 0 + + def try_reproduce(self, dt): + self.reproduction_cooldown = max(0, self.reproduction_cooldown - dt) + + if self.energy > 80 and self.reproduction_cooldown == 0 and self.age > 10: + self.energy -= 30 + self.reproduction_cooldown = 20 # Can't reproduce for 20 time units + return Deer() + return None +``` + +**Realistic Time Scales:** +- **Deer gestation:** 6-7 months → 20-30 game time units +- **Deer maturation:** 1-2 years → 50-100 game time units +- **Wolf gestation:** 2 months → 10-15 game time units + + +### Pitfall 6: No Starvation Mechanics + +**The Mistake:** +```python +# ❌ Animals never die from hunger +if grass == 0: + pass # Deer just stops reproducing, but doesn't die +``` + +**Result:** +- Grass depleted → deer can't eat → but deer live forever +- Population frozen (no deaths, no births) +- Unrealistic (animals should starve) + +**The Fix:** +```python +# ✅ Energy depletion leads to death +class Deer: + def tick(self, dt): + self.energy -= 5 * dt # Metabolism costs energy + + if self.energy <= 0: + self.alive = False + return "starved" + + return "alive" +``` + + +### Pitfall 7: Ignoring Spatial Distribution + +**The Mistake:** +```python +# ❌ All animals exist at same "location" (no space) +prey_population = 100 +predator_population = 20 + +# Predators instantly catch prey (no chase) +prey_population -= predation_rate * prey_population * predator_population +``` + +**Why This Fails:** +- Player hunts specific deer, but they're just numbers +- No herding behavior (deer cluster near food) +- No territorial behavior (wolves patrol territory) +- Less immersive (can't see animals move) + +**When Acceptable:** +- Background populations (distant areas) +- Performance-critical (1000+ animals) +- No player interaction with individuals + +**When Problematic:** +- Hunting game (player targets specific deer) +- Stealth mechanics (sneak past wolves) +- Territory control (protect area from predators) + +**The Fix:** Use agent-based or hybrid approach (Pattern 2 & 3 above). + + +## Real-World Examples + +### Example 1: Minecraft - Simple Spawn System + +**Architecture:** Spawn-based (not true ecosystem simulation) + +**How It Works:** +```python +# Minecraft's approach (simplified) +def spawn_animals(chunk): + """Spawn animals in chunk if below mob cap""" + animal_count = count_animals_in_chunk(chunk) + + if animal_count < 10: # Mob cap per chunk + if random.random() < 0.01: # 1% chance per tick + animal_type = random.choice(['cow', 'pig', 'chicken', 'sheep']) + spawn_position = find_grass_block(chunk) + spawn_animal(animal_type, spawn_position) + +# Breeding (player-driven) +def breed_animals(animal1, animal2): + """Player feeds two animals, they breed""" + if animal1.fed and animal2.fed: + baby = spawn_animal(animal1.type, animal1.position) + animal1.fed = False + animal2.fed = False +``` + +**Not a True Ecosystem:** +- No predator-prey dynamics (no wolves eating cows) +- No natural reproduction (only player-triggered breeding) +- No food chains (animals don't eat grass) +- No population balance (just spawn caps) + +**Why It Works for Minecraft:** +- Simplicity (easy to understand) +- Player control (breeding is gameplay mechanic) +- Predictability (animals don't disappear mysteriously) +- Performance (cheap to implement) + +**Lessons:** +- Don't need full ecosystem for every game +- Spawn caps prevent runaway growth +- Player-driven breeding gives agency + + +### Example 2: Don't Starve - Food Chain Simulation + +**Architecture:** Agent-based with food chains + +**Food Chain:** +``` +Grass/Seeds → Rabbits → Spiders + ↓ ↓ + Player Player +``` + +**How It Works (Conceptual):** +```python +class Rabbit: + def update(self): + # 1. Flee from player and spiders + if see_threat(): + flee() + + # 2. Seek food (grass, carrots) + elif hungry(): + food = find_nearest_food() + if food: + move_toward(food) + if near(food): + eat(food) + + # 3. Reproduce (if well-fed) + if energy > 80 and can_reproduce(): + spawn_rabbit_hole() + + # 4. Return to burrow at night + if is_night(): + return_to_burrow() + +class Spider: + def update(self): + # 1. Hunt rabbits and birds + prey = find_nearest_prey(['rabbit', 'bird', 'player']) + if prey: + chase(prey) + if near(prey): + attack(prey) + + # 2. Return to nest + else: + return_to_nest() +``` + +**Key Mechanics:** +- **Burrows:** Rabbits spawn from burrows (replenishment) +- **Player impact:** Overhunting rabbits → more spiders (less prey) +- **Seasonal:** Winter reduces food, animals starve +- **Extinction prevention:** Burrows slowly spawn new rabbits + +**Lessons:** +- Agent-based works for < 100 animals +- Burrows/nests provide spawn points (extinction prevention) +- Player actions affect balance (hunting creates scarcity) + + +### Example 3: Eco - Full Ecosystem Simulation + +**Architecture:** Agent-based + nutrient cycles + player economy + +**Features:** +- **Plant succession:** Grass → shrubs → trees (over days) +- **Herbivores:** Deer eat plants, need calories +- **Carnivores:** Foxes eat deer, need protein +- **Nutrient cycling:** Dead animals → fertilize plants +- **Player impact:** Deforestation → herbivores starve → carnivores starve → ecosystem collapse + +**Nutrient Cycle (Simplified):** +```python +class EcoSystem: + def __init__(self): + self.soil_nutrients = 1000 + self.plants = [] + self.herbivores = [] + self.carnivores = [] + + def update(self, dt): + # 1. Plants grow using soil nutrients + for plant in self.plants: + if self.soil_nutrients > 0: + plant.grow(dt) + self.soil_nutrients -= plant.nutrient_uptake * dt + + # 2. Herbivores eat plants + for herbivore in self.herbivores: + plant = herbivore.find_nearest_plant() + if plant: + herbivore.eat(plant) + plant.mass -= herbivore.bite_size + + # 3. Carnivores eat herbivores + for carnivore in self.carnivores: + prey = carnivore.find_nearest_prey() + if prey: + carnivore.hunt(prey) + if carnivore.catches(prey): + prey.alive = False + carnivore.eat(prey) + + # 4. Decomposition returns nutrients + for corpse in self.dead_animals: + self.soil_nutrients += corpse.mass * 0.5 # 50% nutrient recovery + + # 5. Player actions + if player.chops_tree(): + tree = self.find_tree() + self.plants.remove(tree) + # Less plants → less food → herbivores starve +``` + +**Goal:** Teach players about ecosystem balance. If you over-harvest, species go extinct. + +**Lessons:** +- Full simulation is HARD (2-4 weeks implementation) +- Nutrient cycling adds depth +- Player education requires visible consequences +- Extinction is a feature (teaches lesson) + + +### Example 4: Spore - Evolutionary Ecosystem + +**Architecture:** Agent-based with genetics + +**Features:** +- **Creatures evolve:** Traits pass from parent to offspring +- **Natural selection:** Weak creatures die, strong survive +- **Predator-prey arms race:** Prey evolves speed → predators evolve speed + +**Genetic System (Simplified):** +```python +class Creature: + def __init__(self, genes=None): + if genes: + self.speed = genes['speed'] + self.strength = genes['strength'] + self.diet = genes['diet'] # 'herbivore' or 'carnivore' + else: + # Random starting genes + self.speed = random.uniform(1, 10) + self.strength = random.uniform(1, 10) + self.diet = random.choice(['herbivore', 'carnivore']) + + def reproduce(self): + """Pass genes to offspring with mutation""" + baby_genes = { + 'speed': self.speed + random.uniform(-0.5, 0.5), # Mutation + 'strength': self.strength + random.uniform(-0.5, 0.5), + 'diet': self.diet + } + return Creature(genes=baby_genes) + +def simulate_evolution(): + creatures = [Creature() for _ in range(100)] + + for generation in range(1000): + # Natural selection + survivors = [] + for creature in creatures: + if creature.survives(): # Depends on speed, strength + survivors.append(creature) + + # Reproduction + creatures = [] + for survivor in survivors: + creatures.append(survivor.reproduce()) + + # Result: Over time, creatures evolve to be faster/stronger +``` + +**Lessons:** +- Evolution = reproduction + mutation + selection +- Emergent complexity (arms race without explicit code) +- Very hard to tune (emergent behavior unpredictable) +- Cool but not necessary for most games + + +### Example 5: The Sims - Abstract Resource Ecosystem + +**Not animals, but same principles:** + +**Resources:** +- **Happiness:** Decreases over time, replenished by fun activities +- **Hunger:** Decreases over time, replenished by eating +- **Social:** Decreases over time, replenished by socializing + +**Ecosystem Analogy:** +```python +# Similar to predator-prey dynamics +# Sims = "predators" consuming resources +# Resources = "prey" being depleted + +class Sim: + def __init__(self): + self.hunger = 50 + self.fun = 50 + self.social = 50 + + def update(self, dt): + # Resources decrease (like prey being eaten) + self.hunger -= 5 * dt + self.fun -= 3 * dt + self.social -= 2 * dt + + # Sims seek resources (like predators hunting) + if self.hunger < 30: + self.go_eat() + elif self.fun < 30: + self.go_play() + elif self.social < 30: + self.go_socialize() + + def go_eat(self): + # Eating replenishes hunger (like prey reproducing) + self.hunger = min(100, self.hunger + 30) +``` + +**Lesson:** Ecosystem principles apply beyond wildlife. Any resource depletion/replenishment system can use Lotka-Volterra ideas. + + +## Cross-References + +### Use This Skill WITH: +- **ai-and-agent-simulation**: Agent-based ecosystems need AI (deer flee, wolves hunt) +- **physics-simulation-patterns**: Animal movement, collision detection +- **economic-simulation-patterns**: Resource extraction (hunting) affects ecosystem + +### Use This Skill BEFORE: +- **procedural-generation**: Populate procedurally generated worlds with wildlife +- **quest-systems**: Quests involving hunting, conservation +- **survival-mechanics**: Food chains, hunting gameplay + +### Related Skills: +- **systems-as-experience**: Ecosystems as narrative/gameplay systems +- **player-driven-economy**: Player hunting affects ecosystem balance +- **difficulty-balancing**: Predator danger scales with player progress + + +## Testing Checklist + +### Stability Validation +- [ ] Ecosystem runs for 10+ minutes without extinction +- [ ] Populations oscillate (not exponential growth or crash) +- [ ] Oscillations dampen over time (settle into stable range) +- [ ] Carrying capacity prevents runaway growth (populations cap out) +- [ ] Extinction prevention activates when populations drop below threshold + +### Parameter Tuning +- [ ] Tested with 3+ different starting conditions (all stable) +- [ ] Adjusted α, β, δ, γ to achieve desired oscillation period +- [ ] Verified populations settle within ±20% of equilibrium +- [ ] Carrying capacities set 20-30% above typical max populations +- [ ] Reproduction cooldowns prevent instant population doubling + +### Edge Cases +- [ ] What if all predators die? (Prey should cap at carrying capacity, not explode) +- [ ] What if all prey die? (Predators should starve, then prey respawn from extinction prevention) +- [ ] What if player hunts 50% of prey? (Population recovers over time) +- [ ] What if grass depleted? (Prey starve until grass regrows) +- [ ] What if 1000 predators spawned? (System handles it without crash, populations rebalance) + +### Performance +- [ ] Runs at 60 FPS with max animal count +- [ ] Agent-based: < 100 animals or use spatial partitioning +- [ ] Equation-based: Can handle 10,000+ population numbers +- [ ] Hybrid: Smoothly transitions between agent/equation modes +- [ ] No memory leaks (dead animals removed from lists) + +### Visualization +- [ ] Population graph shows oscillations over time +- [ ] Can plot prey, predators, resources on same graph +- [ ] Stability metric calculated (variance < 30% of mean) +- [ ] Debug mode shows individual animal states (energy, hunger) +- [ ] Notification when populations critical + +### Realism +- [ ] Energy budgets implemented (eating gains energy, actions cost energy) +- [ ] Reproduction has cooldown (gestation period) +- [ ] Starvation kills animals (energy <= 0 → death) +- [ ] Age-based mortality (animals die of old age eventually) +- [ ] Spatial distribution makes sense (prey near food, predators near prey) + +### Gameplay Integration +- [ ] Player hunting reduces prey population (visible impact) +- [ ] Ecosystem recovers from player over-hunting (migration, respawn) +- [ ] Extinction warning shown to player ("Deer population critical!") +- [ ] Player can observe ecosystem health (population stats, graphs) +- [ ] Ecosystem state saved/loaded correctly (populations persist) + + +## Summary + +Ecosystem simulation for games requires understanding **Lotka-Volterra equations**, **carrying capacity**, **energy budgets**, and **extinction prevention**. The core principles are: + +1. **Use Lotka-Volterra as foundation** - Provides natural predator-prey oscillations +2. **Add carrying capacity** - Prevents runaway growth and crashes +3. **Implement extinction prevention** - Soft boundaries keep ecosystem alive +4. **Choose right approach** - Equation-based for speed, agent-based for detail, hybrid for scale +5. **Add time delays** - Reproduction cooldowns prevent instant explosions +6. **Energy budgets** - Animals need food to survive and reproduce +7. **Test stability rigorously** - Run for 10+ minutes, check for explosions/extinctions +8. **Balance realism with fun** - Don't let ecosystem collapse ruin gameplay + +**Most Common Failures:** +- ❌ No carrying capacity → runaway growth +- ❌ No extinction prevention → collapse within minutes +- ❌ No Lotka-Volterra understanding → chaotic, untunable +- ❌ Instant reproduction → exponential explosions +- ❌ No starvation mechanics → unrealistic immortal animals + +**Success Pattern:** +```python +# Lotka-Volterra + carrying capacity + extinction prevention +prey += (alpha * prey * (1 - prey/K_prey) - beta * prey * predators) * dt +predators += (delta * beta * prey * predators - gamma * predators) * dt + +if prey < MIN_PREY: prey += recovery_rate * dt +if predators < MIN_PREDATORS: predators += recovery_rate * dt + +prey = min(prey, K_prey) +predators = min(predators, K_predators) +``` + +Master these patterns, avoid the pitfalls, and your ecosystem will be stable, engaging, and scalable. diff --git a/skills/using-simulation-tactics/performance-optimization-for-sims.md b/skills/using-simulation-tactics/performance-optimization-for-sims.md new file mode 100644 index 0000000..678da9c --- /dev/null +++ b/skills/using-simulation-tactics/performance-optimization-for-sims.md @@ -0,0 +1,2573 @@ + +# Performance Optimization for Simulations + +**When to use this skill**: When simulations run below target frame rate (typically 60 FPS for PC, 30 FPS for mobile), especially with large agent counts (100+ units), complex AI, physics calculations, or proximity queries. Critical for RTS games, crowd simulations, ecosystem models, traffic systems, and any scenario requiring 1000+ active entities. + +**What this skill provides**: Systematic methodology for performance optimization using profiling-driven decisions, spatial partitioning patterns, level-of-detail (LOD) systems, time-slicing, caching strategies, data-oriented design, and selective multithreading. Focuses on achieving 60 FPS at scale while maintaining gameplay quality. + + +## Core Concepts + +### The Optimization Hierarchy (Critical Order) + +**ALWAYS optimize in this order** - each level provides 10-100× improvement: + +1. **PROFILE FIRST** (0.5-1 hour investment) + - Identify actual bottleneck with profiler + - Measure baseline performance + - Set target frame time budgets + - **Never guess** - 80% of time is usually in 20% of code + +2. **Algorithmic Optimizations** (10-100× improvement) + - Fix O(n²) → O(n) or O(n log n) + - Spatial partitioning for proximity queries + - Replace brute-force with smart algorithms + - **Biggest wins**, do these FIRST + +3. **Level of Detail (LOD)** (2-10× improvement) + - Reduce computation for distant/unimportant entities + - Smooth transitions (no popping) + - Priority-based update frequencies + - Behavior LOD + visual LOD + +4. **Time-Slicing** (2-5× improvement) + - Spread work across multiple frames + - Frame time budgets per system + - Priority queues for important work + - Amortized expensive operations + +5. **Caching** (2-10× improvement) + - Avoid redundant calculations + - LRU eviction + TTL + - Proper invalidation + - Bounded memory usage + +6. **Data-Oriented Design** (1.5-3× improvement) + - Cache-friendly memory layouts + - Struct of Arrays (SoA) vs Array of Structs (AoS) + - Minimize pointer chasing + - Batch operations on contiguous data + +7. **Multithreading** (1.5-4× improvement) + - ONLY if still needed after above + - Job systems for data parallelism + - Avoid locks and race conditions + - Complexity cost is high + +**Example**: RTS with 1000 units at 10 FPS → 60 FPS +- Profile: Vision checks are 80% of frame time +- Spatial partitioning: O(n²) → O(n) = 50× faster → 40 FPS +- LOD: Distant units update less = 1.5× faster → 60 FPS +- Done in 30 minutes vs 2 hours of trial-and-error + +### Profiling Methodology + +**Three-step profiling process**: + +1. **Capture Baseline** (before optimization) + - Total frame time + - Time per major system (AI, physics, rendering, pathfinding) + - CPU vs GPU bound + - Memory allocations per frame + - Cache misses (if profiler supports) + +2. **Identify Bottleneck** (80/20 rule) + - Sort functions by time spent + - Focus on top 3-5 functions (usually 80% of time) + - Understand WHY they're slow (algorithm, data layout, cache misses) + +3. **Validate Improvement** (after each optimization) + - Measure same metrics + - Calculate speedup ratio + - Check for regressions (new bottlenecks) + - Iterate until target met + +**Profiling Tools**: +- **Python**: cProfile, line_profiler, memory_profiler, py-spy +- **C++**: VTune, perf, Instruments (Mac), Very Sleepy +- **Unity**: Unity Profiler, Deep Profile mode +- **Unreal**: Unreal Insights, stat commands +- **Browser**: Chrome DevTools Performance tab + +**Example Profiling Output**: +``` +Total frame time: 100ms (10 FPS) + +Function Time % of Frame +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +update_vision_checks() 80ms 80% ← BOTTLENECK +update_ai() 10ms 10% +update_pathfinding() 5ms 5% +update_physics() 3ms 3% +render() 2ms 2% + +Diagnosis: O(n²) vision checks (1000 units × 1000 = 1M checks/frame) +Solution: Spatial partitioning → O(n) checks +``` + +### Spatial Partitioning + +**Problem**: Proximity queries are O(n²) when checking every entity against every other +- 100 entities = 10,000 checks +- 1,000 entities = 1,000,000 checks (death) +- 10,000 entities = 100,000,000 checks (impossible) + +**Solution**: Divide space into regions, only check entities in nearby regions + +**Spatial Hash Grid** (simplest, fastest for uniform distribution) +- Divide world into fixed-size cells (e.g., 50×50 units) +- Hash entity position to cell(s) +- Query: Check only entities in neighboring cells +- Complexity: O(n) to build, O(1) average query +- Best for: Mostly uniform entity distribution + +**Quadtree** (adaptive, good for clustered entities) +- Recursively subdivide space into 4 quadrants +- Split when cell exceeds threshold (e.g., 10 entities) +- Query: Descend tree, check overlapping nodes +- Complexity: O(n log n) to build, O(log n) average query +- Best for: Entities clustered in areas + +**Octree** (3D version of quadtree) +- Recursively subdivide 3D space into 8 octants +- Same benefits as quadtree for 3D worlds +- Best for: 3D flight sims, space games, underwater + +**Decision Framework**: +``` +Spatial Partitioning Choice: + +├─ 2D WORLD with UNIFORM DISTRIBUTION? +│ └─ Use Spatial Hash Grid (simplest, fastest) +│ +├─ 2D WORLD with CLUSTERED ENTITIES? +│ └─ Use Quadtree (adapts to density) +│ +├─ 3D WORLD? +│ └─ Use Octree (3D quadtree) +│ +└─ VERY LARGE WORLD (multiple km²)? + └─ Use Hierarchical Grid (multiple grids at different scales) +``` + +**Performance Impact**: +- 1000 units: O(n²) = 1,000,000 checks → O(n) = 1,000 checks = **1000× faster** +- Typical speedup: 50-100× in practice (accounting for grid overhead) + +### Level of Detail (LOD) + +**Concept**: Reduce computation for entities that don't need full precision + +**Distance-Based LOD Levels**: +- **LOD 0** (0-50 units from camera): Full detail + - Full AI decision-making (10 Hz) + - Precise pathfinding + - Detailed animations + - All visual effects + +- **LOD 1** (50-100 units): Reduced detail + - Simplified AI (5 Hz) + - Coarse pathfinding (waypoints only) + - Simplified animations + - Reduced effects + +- **LOD 2** (100-200 units): Minimal detail + - Basic AI (1 Hz) + - Straight-line movement + - Static pose or simple animation + - No effects + +- **LOD 3** (200+ units): Culled or dormant + - State update only (0.2 Hz) + - No pathfinding + - Billboards or invisible + - No physics + +**Importance-Based LOD** (better than distance alone): +```python +def calculate_lod_level(entity, camera, player): + # Multiple factors determine importance + distance = entity.distance_to(camera) + is_player_unit = entity.team == player.team + is_in_combat = entity.in_combat + is_selected = entity in player.selection + + # Important entities always get high LOD + if is_selected: + return 0 # Always full detail + if is_player_unit and is_in_combat: + return 0 # Player's units in combat = critical + + # Distance-based for others + if distance < 50: + return 0 + elif distance < 100: + return 1 + elif distance < 200: + return 2 + else: + return 3 +``` + +**Smooth LOD Transitions** (avoid popping): +- **Hysteresis**: Different thresholds for upgrading vs downgrading + - Upgrade LOD at 90 units + - Downgrade LOD at 110 units + - 20-unit buffer prevents thrashing + +- **Time delay**: Wait N seconds before downgrading LOD + - Prevents rapid flicker at boundary + +- **Blend animations**: Cross-fade between LOD levels + - 0.5-1 second blend + +**Behavior LOD Examples**: + +| System | LOD 0 (Full) | LOD 1 (Reduced) | LOD 2 (Minimal) | LOD 3 (Dormant) | +|--------|--------------|-----------------|-----------------|-----------------| +| **AI** | Behavior tree 10 Hz | Simple FSM 5 Hz | Follow path 1 Hz | State only 0.2 Hz | +| **Pathfinding** | Full A* | Hierarchical | Straight line | None | +| **Vision** | 360° scan 10 Hz | Forward cone 5 Hz | None | None | +| **Physics** | Full collision | Bounding box | None | None | +| **Animation** | Full skeleton | 5 bones | Static pose | None | +| **Audio** | 3D positioned | 2D ambient | None | None | + +**Performance Impact**: +- 1000 units: 100% at LOD 0 vs 20% at LOD 0 + 80% at LOD 1-3 = **3-5× faster** + +### Time-Slicing + +**Concept**: Spread expensive operations across multiple frames to stay within frame budget + +**Frame Time Budget** (60 FPS = 16.67ms per frame): +``` +Frame Budget (16.67ms total): +├─ Rendering: 6ms (40%) +├─ AI: 4ms (24%) +├─ Physics: 3ms (18%) +├─ Pathfinding: 2ms (12%) +└─ Other: 1.67ms (10%) +``` + +**Time-Slicing Pattern 1: Fixed Budget Per Frame** +```python +class TimeSlicedSystem: + def __init__(self, budget_ms=2.0): + self.budget = budget_ms + self.pending_work = [] + + def add_work(self, work_item, priority=0): + # Priority queue: higher priority = processed first + heapq.heappush(self.pending_work, (-priority, work_item)) + + def update(self, dt): + start_time = time.time() + processed = 0 + + while self.pending_work and (time.time() - start_time) < self.budget: + priority, work_item = heapq.heappop(self.pending_work) + work_item.execute() + processed += 1 + + return processed + +# Usage: Pathfinding +pathfinding_system = TimeSlicedSystem(budget_ms=2.0) + +for unit in units_needing_paths: + priority = calculate_priority(unit) # Player units = high priority + pathfinding_system.add_work(PathfindRequest(unit), priority) + +# Each frame: process as many as fit in 2ms budget +paths_found = pathfinding_system.update(dt) +``` + +**Time-Slicing Pattern 2: Amortized Updates** +```python +class AmortizedUpdateManager: + def __init__(self, entities, updates_per_frame=200): + self.entities = entities + self.updates_per_frame = updates_per_frame + self.current_index = 0 + + def update(self, dt): + # Update N entities per frame + for i in range(self.updates_per_frame): + entity = self.entities[self.current_index] + entity.expensive_update(dt) + + self.current_index = (self.current_index + 1) % len(self.entities) + + # All entities updated every N frames + # 1000 entities / 200 per frame = every 5 frames = 12 Hz at 60 FPS + +# Priority-based amortization +def update_with_priority(entities, frame_count): + for i, entity in enumerate(entities): + # Distance-based update frequency + distance = entity.distance_to_camera() + + if distance < 50: + entity.update() # Every frame (60 Hz) + elif distance < 100 and frame_count % 2 == 0: + entity.update() # Every 2 frames (30 Hz) + elif distance < 200 and frame_count % 5 == 0: + entity.update() # Every 5 frames (12 Hz) + elif frame_count % 30 == 0: + entity.update() # Every 30 frames (2 Hz) +``` + +**Time-Slicing Pattern 3: Incremental Processing** +```python +class IncrementalPathfinder: + """Find path over multiple frames instead of blocking""" + + def __init__(self, max_nodes_per_frame=100): + self.max_nodes = max_nodes_per_frame + self.open_set = [] + self.closed_set = set() + self.current_request = None + + def start_pathfind(self, start, goal): + self.current_request = PathRequest(start, goal) + heapq.heappush(self.open_set, (0, start)) + return self.current_request + + def step(self): + """Process up to max_nodes this frame, return True if done""" + if not self.current_request: + return True + + nodes_processed = 0 + + while self.open_set and nodes_processed < self.max_nodes: + current = heapq.heappop(self.open_set) + + if current == self.current_request.goal: + self.current_request.path = reconstruct_path(current) + self.current_request.complete = True + return True + + # Expand neighbors... + nodes_processed += 1 + + return False # Not done yet, continue next frame + +# Usage +pathfinder = IncrementalPathfinder(max_nodes_per_frame=100) +request = pathfinder.start_pathfind(unit.pos, target.pos) + +# Each frame +while not request.complete: + pathfinder.step() # Process 100 nodes, spread over multiple frames +``` + +**Performance Impact**: +- 1000 expensive updates: 1000/frame → 200/frame = **5× faster** +- Pathfinding: Blocking 50ms → 2ms budget = stays at 60 FPS + +### Caching Strategies + +**When to Cache**: +- Expensive calculations used repeatedly (pathfinding, line-of-sight) +- Results that change infrequently (static paths, terrain visibility) +- Deterministic results (same input = same output) + +**Cache Design Pattern**: +```python +class PerformanceCache: + def __init__(self, max_size=10000, ttl_seconds=60.0): + self.cache = {} # key -> CacheEntry + self.max_size = max_size + self.ttl = ttl_seconds + self.access_times = {} # LRU tracking + self.insert_times = {} # TTL tracking + + def get(self, key): + current_time = time.time() + + if key not in self.cache: + return None + + # Check TTL (time-to-live) + if current_time - self.insert_times[key] > self.ttl: + del self.cache[key] + del self.access_times[key] + del self.insert_times[key] + return None + + # Update LRU + self.access_times[key] = current_time + return self.cache[key] + + def put(self, key, value): + current_time = time.time() + + # Evict if full (LRU eviction) + if len(self.cache) >= self.max_size: + # Find least recently used + lru_key = min(self.access_times, key=self.access_times.get) + del self.cache[lru_key] + del self.access_times[lru_key] + del self.insert_times[lru_key] + + self.cache[key] = value + self.access_times[key] = current_time + self.insert_times[key] = current_time + + def invalidate(self, key): + """Explicit invalidation when data changes""" + if key in self.cache: + del self.cache[key] + del self.access_times[key] + del self.insert_times[key] + + def invalidate_region(self, x, y, radius): + """Invalidate all cache entries in region (e.g., terrain changed)""" + keys_to_remove = [] + for key in self.cache: + if self._key_in_region(key, x, y, radius): + keys_to_remove.append(key) + + for key in keys_to_remove: + self.invalidate(key) + +# Usage: Path caching +path_cache = PerformanceCache(max_size=5000, ttl_seconds=30.0) + +def get_or_calculate_path(start, goal): + # Quantize to grid for cache key (allow slight position variance) + key = (round(start.x), round(start.y), round(goal.x), round(goal.y)) + + cached = path_cache.get(key) + if cached: + return cached # Cache hit! + + # Cache miss - calculate + path = expensive_pathfinding(start, goal) + path_cache.put(key, path) + return path + +# Invalidate when terrain changes +def on_building_placed(x, y): + path_cache.invalidate_region(x, y, radius=100) +``` + +**Cache Invalidation Strategies**: + +1. **Time-To-Live (TTL)**: Expire after N seconds + - Good for: Dynamic environments (traffic, weather) + - Example: Path cache with 30 second TTL + +2. **Event-Based**: Invalidate on specific events + - Good for: Known change triggers (building placed, obstacle moved) + - Example: Invalidate paths when wall built + +3. **Hybrid**: TTL + event-based + - Good for: Most scenarios + - Example: 60 second TTL OR invalidate on terrain change + +**Performance Impact**: +- Pathfinding with 60% cache hit rate: 40% of requests calculate = **2.5× faster** +- Line-of-sight with 80% cache hit rate: 20% of requests calculate = **5× faster** + +### Data-Oriented Design (DOD) + +**Concept**: Organize data for cache-friendly access patterns + +**Array of Structs (AoS)** - Traditional OOP approach: +```python +class Unit: + def __init__(self): + self.x = 0.0 + self.y = 0.0 + self.health = 100 + self.damage = 10 + # ... 20 more fields ... + +units = [Unit() for _ in range(1000)] + +# Update positions (cache-unfriendly) +for unit in units: + unit.x += unit.velocity_x * dt # Load entire Unit struct for each unit + unit.y += unit.velocity_y * dt # Only using 2 fields, wasting cache +``` + +**Struct of Arrays (SoA)** - DOD approach: +```python +class UnitSystem: + def __init__(self, count): + # Separate arrays for each component + self.positions_x = [0.0] * count + self.positions_y = [0.0] * count + self.velocities_x = [0.0] * count + self.velocities_y = [0.0] * count + self.health = [100] * count + self.damage = [10] * count + # ... more arrays ... + +units = UnitSystem(1000) + +# Update positions (cache-friendly) +for i in range(len(units.positions_x)): + units.positions_x[i] += units.velocities_x[i] * dt # Sequential memory access + units.positions_y[i] += units.velocities_y[i] * dt # Perfect for CPU cache +``` + +**Why SoA is Faster**: +- CPU cache lines are 64 bytes +- AoS: Load 1-2 units per cache line (if Unit is 32-64 bytes) +- SoA: Load 8-16 floats per cache line (4 bytes each) +- **4-8× better cache utilization** = 1.5-3× faster in practice + +**When to Use SoA**: +- Batch operations on many entities (position updates, damage calculations) +- Systems that only need 1-2 fields from entity +- Performance-critical inner loops + +**When AoS is Okay**: +- Small entity counts (< 100) +- Operations needing many fields +- Prototyping (DOD is optimization, not default) + +**ECS Architecture** (combines SoA + component composition): +```python +# Components (pure data) +class Position: + x: float + y: float + +class Velocity: + x: float + y: float + +class Health: + current: int + max: int + +# Systems (pure logic) +class MovementSystem: + def update(self, positions, velocities, dt): + # Batch process all entities with Position + Velocity + for i in range(len(positions)): + positions[i].x += velocities[i].x * dt + positions[i].y += velocities[i].y * dt + +class CombatSystem: + def update(self, positions, health, attacks): + # Only process entities with Position + Health + Attack + # ... + +# Entity is just an ID +entities = [Entity(id=i) for i in range(1000)] +``` + +**Performance Impact**: +- Cache-friendly data layout: 1.5-3× faster for batch operations +- ECS architecture: Enables efficient multithreading (no shared mutable state) + +### Multithreading (Use Sparingly) + +**When to Multithread**: +- ✅ After all other optimizations (if still needed) +- ✅ Embarrassingly parallel work (no dependencies) +- ✅ Long-running tasks (benefit outweighs overhead) +- ✅ Native code (C++, Rust) - avoids GIL + +**When NOT to Multithread**: +- ❌ Python CPU-bound code (GIL limits to 1 core) +- ❌ Before trying simpler optimizations +- ❌ Lots of shared mutable state (locking overhead) +- ❌ Small tasks (thread overhead > savings) + +**Job System Pattern** (best practice): +```python +from concurrent.futures import ThreadPoolExecutor +import threading + +class JobSystem: + def __init__(self, num_workers=4): + self.executor = ThreadPoolExecutor(max_workers=num_workers) + + def submit_batch(self, jobs): + """Submit list of independent jobs, return futures""" + futures = [self.executor.submit(job.execute) for job in jobs] + return futures + + def wait_all(self, futures): + """Wait for all jobs to complete""" + results = [future.result() for future in futures] + return results + +# Good: Parallel pathfinding (independent tasks) +job_system = JobSystem(num_workers=4) + +path_jobs = [PathfindJob(unit.pos, unit.target) for unit in units_needing_paths] +futures = job_system.submit_batch(path_jobs) + +# Do other work while pathfinding runs... + +# Collect results +paths = job_system.wait_all(futures) +``` + +**Data Parallelism Pattern** (no shared mutable state): +```python +def update_positions_parallel(positions, velocities, dt, num_workers=4): + """Update positions in parallel batches""" + + def update_batch(start_idx, end_idx): + # Each worker gets exclusive slice (no locks needed) + for i in range(start_idx, end_idx): + positions[i].x += velocities[i].x * dt + positions[i].y += velocities[i].y * dt + + # Split work into batches + batch_size = len(positions) // num_workers + futures = [] + + for worker_id in range(num_workers): + start = worker_id * batch_size + end = start + batch_size if worker_id < num_workers - 1 else len(positions) + future = executor.submit(update_batch, start, end) + futures.append(future) + + # Wait for all workers + for future in futures: + future.result() +``` + +**Common Multithreading Pitfalls**: + +1. **Race Conditions** (shared mutable state) + ```python + # BAD: Multiple threads modifying same list + for unit in units: + threading.Thread(target=unit.update, args=(all_units,)).start() + # Each thread reads/writes all_units = data race! + + # GOOD: Read-only shared data + for unit in units: + # units is read-only for all threads + # Each unit only modifies itself (exclusive ownership) + threading.Thread(target=unit.update, args=(units,)).start() + ``` + +2. **False Sharing** (cache line contention) + ```python + # BAD: Adjacent array elements on same cache line + shared_counters = [0] * 8 # 8 threads updating 8 counters + # Thread 0 updates counter[0], Thread 1 updates counter[1] + # Both on same 64-byte cache line = cache thrashing! + + # GOOD: Pad to separate cache lines + class PaddedCounter: + value: int + padding: [int] * 15 # Force to own cache line + + shared_counters = [PaddedCounter() for _ in range(8)] + ``` + +3. **Excessive Locking** (defeats parallelism) + ```python + # BAD: Single lock for everything + lock = threading.Lock() + + def update_unit(unit): + with lock: # Only 1 thread can work at a time! + unit.update() + + # GOOD: Lock-free or fine-grained locking + def update_unit(unit): + unit.update() # Each unit independent, no lock needed + ``` + +**Performance Impact**: +- 4 cores: Ideal speedup = 4×, realistic = 2-3× (overhead, Amdahl's law) +- Python: Minimal (GIL), use multiprocessing or native extensions +- C++/Rust: Good (2-3× on 4 cores for parallelizable work) + + +## Decision Frameworks + +### Framework 1: Systematic Optimization Process + +**Use this process EVERY time performance is inadequate**: + +``` +Step 1: PROFILE (mandatory, do first) +├─ Capture baseline metrics +├─ Identify top 3-5 bottlenecks (80% of time) +└─ Understand WHY slow (algorithm, data, cache) + +Step 2: ALGORITHMIC (10-100× gains) +├─ Is bottleneck O(n²) or worse? +│ ├─ Proximity queries? → Spatial partitioning +│ ├─ Pathfinding? → Hierarchical, flow fields, or caching +│ └─ Sorting? → Better algorithm or less frequent +├─ Is bottleneck doing redundant work? +│ └─ Add caching with LRU + TTL +└─ Measure improvement, re-profile + +Step 3: LOD (2-10× gains) +├─ Can distant entities use less detail? +│ ├─ Distance-based LOD levels (4 levels) +│ ├─ Importance weighting (player units > NPC) +│ └─ Smooth transitions (hysteresis, blending) +└─ Measure improvement, re-profile + +Step 4: TIME-SLICING (2-5× gains) +├─ Can work spread across multiple frames? +│ ├─ Set frame budget per system (2-4ms typical) +│ ├─ Priority queue (important work first) +│ └─ Amortized updates (N entities per frame) +└─ Measure improvement, re-profile + +Step 5: DATA-ORIENTED DESIGN (1.5-3× gains) +├─ Is bottleneck cache-unfriendly? +│ ├─ Convert AoS → SoA for batch operations +│ ├─ Group hot data together +│ └─ Minimize pointer chasing +└─ Measure improvement, re-profile + +Step 6: MULTITHREADING (1.5-4× gains, high complexity) +├─ Still below target after above? +│ ├─ Identify embarrassingly parallel work +│ ├─ Job system for independent tasks +│ ├─ Data parallelism (no shared mutable state) +│ └─ Avoid locks (lock-free or per-entity ownership) +└─ Measure improvement, re-profile + +Step 7: VALIDATE +├─ Met target frame rate? → Done! +├─ Still slow? → Return to Step 1, find new bottleneck +└─ Regression? → Revert and try different approach +``` + +**Example Application** (1000-unit RTS at 10 FPS): +1. Profile: Vision checks are 80% (80ms/100ms frame) +2. Algorithmic: Add spatial hash grid → 40 FPS (15ms vision checks) +3. LOD: Distant units update at 5 Hz → 55 FPS (11ms vision) +4. Time-slicing: 2ms pathfinding budget → 60 FPS ✅ **Done** +5. (Skip DOD and multithreading - already at target) + +### Framework 2: Choosing Spatial Partitioning + +``` +START: What's my proximity query scenario? + +├─ 2D WORLD with UNIFORM ENTITY DISTRIBUTION? +│ └─ Use SPATIAL HASH GRID +│ - Cell size = 2× query radius (e.g., vision range 50 → cells 100×100) +│ - O(n) build, O(1) query +│ - Simplest to implement +│ - Example: RTS units on open battlefield +│ +├─ 2D WORLD with CLUSTERED ENTITIES? +│ └─ Use QUADTREE +│ - Split threshold = 10-20 entities per node +│ - Max depth = 8-10 levels +│ - O(n log n) build, O(log n) query +│ - Example: City simulation (dense downtown, sparse suburbs) +│ +├─ 3D WORLD? +│ └─ Use OCTREE +│ - Same as quadtree, but 8 children per node +│ - Example: Space game, underwater sim +│ +├─ VERY LARGE WORLD (> 10 km²)? +│ └─ Use HIERARCHICAL GRID +│ - Coarse grid (1km cells) + fine grid (50m cells) per coarse cell +│ - Example: MMO world, open-world game +│ +└─ ENTITIES MOSTLY STATIONARY? + └─ Use STATIC QUADTREE/OCTREE + - Build once, query many times + - Example: Building placement, static obstacles +``` + +**Implementation Complexity**: +- Spatial Hash Grid: **1-2 hours** (simple) +- Quadtree: **3-5 hours** (moderate) +- Octree: **4-6 hours** (moderate) +- Hierarchical Grid: **6-10 hours** (complex) + +**Performance Characteristics**: + +| Method | Build Time | Query Time | Memory | Best For | +|--------|------------|------------|--------|----------| +| Hash Grid | O(n) | O(1) avg | Low | Uniform distribution | +| Quadtree | O(n log n) | O(log n) avg | Medium | Clustered entities | +| Octree | O(n log n) | O(log n) avg | Medium | 3D worlds | +| Hierarchical | O(n) | O(1) avg | Higher | Massive worlds | + +### Framework 3: LOD Level Assignment + +``` +For each entity, assign LOD level based on: + +├─ IMPORTANCE (highest priority) +│ ├─ Player-controlled? → LOD 0 (always full detail) +│ ├─ Player's team AND in combat? → LOD 0 +│ ├─ Selected units? → LOD 0 +│ ├─ Quest-critical NPCs? → LOD 0 +│ └─ Otherwise, use distance-based... +│ +├─ DISTANCE FROM CAMERA (secondary) +│ ├─ 0-50 units → LOD 0 (full detail) +│ │ - Update: 60 Hz (every frame) +│ │ - AI: Full behavior tree +│ │ - Pathfinding: Precise A* +│ │ - Animation: Full skeleton +│ │ +│ ├─ 50-100 units → LOD 1 (reduced) +│ │ - Update: 30 Hz (every 2 frames) +│ │ - AI: Simplified FSM +│ │ - Pathfinding: Hierarchical +│ │ - Animation: 10 bones +│ │ +│ ├─ 100-200 units → LOD 2 (minimal) +│ │ - Update: 12 Hz (every 5 frames) +│ │ - AI: Basic scripted +│ │ - Pathfinding: Waypoints +│ │ - Animation: Static pose +│ │ +│ └─ 200+ units → LOD 3 (culled) +│ - Update: 2 Hz (every 30 frames) +│ - AI: State only (no decisions) +│ - Pathfinding: None +│ - Animation: None (invisible or billboard) +│ +└─ SCREEN SIZE (tertiary) + ├─ Occluded or < 5 pixels? → LOD 3 (culled) + └─ Small on screen? → Bump LOD down 1 level +``` + +**Hysteresis to Prevent LOD Thrashing**: +```python +# Without hysteresis (bad - flickers) +lod = 0 if distance < 100 else 1 +# Entity at 99-101 units: LOD flip-flops every frame! + +# With hysteresis (good - stable) +if distance < 90: + lod = 0 # Upgrade at 90 +elif distance > 110: + lod = 1 # Downgrade at 110 +# else: keep current LOD +# 20-unit buffer prevents thrashing +``` + +### Framework 4: When to Use Multithreading + +``` +Should I multithread this system? + +├─ ALREADY optimized algorithmic/LOD/caching? +│ └─ NO → Do those FIRST (10-100× gains vs 2-4× for threading) +│ +├─ WORK IS EMBARRASSINGLY PARALLEL? +│ ├─ Independent tasks (pathfinding requests)? → YES, good candidate +│ ├─ Lots of shared mutable state? → NO, locking kills performance +│ └─ Need results immediately? → NO, adds latency +│ +├─ TASK DURATION > 1ms? +│ ├─ YES → Threading overhead is small % of work +│ └─ NO → Overhead dominates, not worth it +│ +├─ PYTHON or NATIVE CODE? +│ ├─ Python → Use multiprocessing (avoid GIL) or native extensions +│ └─ C++/Rust → ThreadPool or job system works well +│ +├─ COMPLEXITY COST JUSTIFIED? +│ ├─ Can maintain code with debugging difficulty? → Consider it +│ └─ Team inexperienced with threading? → Avoid (bugs are costly) +│ +└─ EXPECTED SPEEDUP > 1.5×? + ├─ 4 cores: Realistic = 2-3× (not 4× due to overhead) + ├─ Worth complexity? → Your call + └─ Not worth it? → Try other optimizations first +``` + +**Threading Decision Tree Example**: +``` +Scenario: Pathfinding for 100 units + +├─ Already using caching? YES (60% hit rate) +├─ Work is parallel? YES (each path independent) +├─ Task duration? 5ms per path (good for threading) +├─ Language? Python (GIL problem) +│ └─ Solution: Use multiprocessing or native pathfinding library +├─ Complexity justified? 100 paths × 5ms = 500ms → 60ms with 8 workers +│ └─ YES, worth it (8× speedup) +│ +Decision: Use multiprocessing.Pool with 8 workers +``` + +### Framework 5: Frame Time Budget Allocation + +**60 FPS = 16.67ms per frame, 30 FPS = 33.33ms per frame** + +**Budget Template** (adjust based on game type): + +``` +60 FPS Frame Budget (16.67ms total): + +├─ Rendering: 6.0ms (40%) +│ ├─ Culling: 1.0ms +│ ├─ Draw calls: 4.0ms +│ └─ Post-processing: 1.0ms +│ +├─ AI: 3.5ms (24%) +│ ├─ Behavior trees: 2.0ms +│ ├─ Sensors/perception: 1.0ms +│ └─ Decision-making: 0.5ms +│ +├─ Physics: 3.0ms (18%) +│ ├─ Broad-phase: 0.5ms +│ ├─ Narrow-phase: 1.5ms +│ └─ Constraint solving: 1.0ms +│ +├─ Pathfinding: 2.0ms (12%) +│ ├─ New paths: 1.5ms +│ └─ Path following: 0.5ms +│ +├─ Gameplay: 1.0ms (6%) +│ ├─ Economy updates: 0.3ms +│ ├─ Event processing: 0.4ms +│ └─ UI updates: 0.3ms +│ +└─ Buffer: 1.17ms (7%) + └─ Unexpected spikes, GC, etc. +``` + +**Budget by Game Type**: + +| Game Type | Rendering | AI | Physics | Pathfinding | Gameplay | +|-----------|-----------|-----|---------|-------------|----------| +| **RTS** | 30% | 30% | 10% | 20% | 10% | +| **FPS** | 50% | 15% | 20% | 5% | 10% | +| **City Builder** | 35% | 20% | 5% | 15% | 25% | +| **Physics Sim** | 30% | 5% | 50% | 5% | 10% | +| **Turn-Based** | 60% | 15% | 5% | 10% | 10% | + +**Enforcement Pattern**: +```python +class FrameBudgetMonitor: + def __init__(self): + self.budgets = { + 'rendering': 6.0, + 'ai': 3.5, + 'physics': 3.0, + 'pathfinding': 2.0, + 'gameplay': 1.0 + } + self.measurements = {key: [] for key in self.budgets} + + def measure(self, system_name, func): + start = time.perf_counter() + result = func() + elapsed_ms = (time.perf_counter() - start) * 1000 + + self.measurements[system_name].append(elapsed_ms) + + # Alert if over budget + if elapsed_ms > self.budgets[system_name]: + print(f"⚠️ {system_name} over budget: {elapsed_ms:.2f}ms / {self.budgets[system_name]:.2f}ms") + + return result + + def report(self): + print("Frame Time Budget Report:") + for system, budget in self.budgets.items(): + avg = sum(self.measurements[system]) / len(self.measurements[system]) + pct = (avg / budget) * 100 + print(f" {system}: {avg:.2f}ms / {budget:.2f}ms ({pct:.0f}%)") + +# Usage +monitor = FrameBudgetMonitor() + +def game_loop(): + monitor.measure('ai', lambda: update_ai(units)) + monitor.measure('physics', lambda: update_physics(world)) + monitor.measure('pathfinding', lambda: update_pathfinding(units)) + monitor.measure('rendering', lambda: render_scene(camera)) + + if frame_count % 300 == 0: # Every 5 seconds + monitor.report() +``` + + +## Implementation Patterns + +### Pattern 1: Spatial Hash Grid for Proximity Queries + +**Problem**: Checking every unit against every other unit for vision/attack is O(n²) +- 1000 units = 1,000,000 checks per frame = death + +**Solution**: Spatial hash grid divides world into cells, only check nearby cells + +```python +import math +from collections import defaultdict + +class SpatialHashGrid: + """ + Spatial partitioning using hash grid for O(1) average query time. + + Best for: Uniform entity distribution, 2D worlds + Cell size rule: 2× maximum query radius + """ + + def __init__(self, cell_size=100): + self.cell_size = cell_size + self.grid = defaultdict(list) # (cell_x, cell_y) -> [entities] + + def _hash(self, x, y): + """Convert world position to cell coordinates""" + cell_x = int(math.floor(x / self.cell_size)) + cell_y = int(math.floor(y / self.cell_size)) + return (cell_x, cell_y) + + def clear(self): + """Clear all entities (call at start of frame)""" + self.grid.clear() + + def insert(self, entity): + """Insert entity into grid""" + cell = self._hash(entity.x, entity.y) + self.grid[cell].append(entity) + + def query_radius(self, x, y, radius): + """ + Find all entities within radius of (x, y). + + Returns: List of entities in range + Complexity: O(k) where k = entities in nearby cells (typically 10-50) + """ + # Calculate which cells to check + min_cell_x = int(math.floor((x - radius) / self.cell_size)) + max_cell_x = int(math.floor((x + radius) / self.cell_size)) + min_cell_y = int(math.floor((y - radius) / self.cell_size)) + max_cell_y = int(math.floor((y + radius) / self.cell_size)) + + candidates = [] + + # Check all cells in range + for cell_x in range(min_cell_x, max_cell_x + 1): + for cell_y in range(min_cell_y, max_cell_y + 1): + cell = (cell_x, cell_y) + candidates.extend(self.grid.get(cell, [])) + + # Filter by exact distance (candidates may be outside radius) + results = [] + radius_sq = radius * radius + + for entity in candidates: + dx = entity.x - x + dy = entity.y - y + dist_sq = dx * dx + dy * dy + + if dist_sq <= radius_sq: + results.append(entity) + + return results + + def query_rect(self, min_x, min_y, max_x, max_y): + """Find all entities in rectangular region""" + min_cell_x = int(math.floor(min_x / self.cell_size)) + max_cell_x = int(math.floor(max_x / self.cell_size)) + min_cell_y = int(math.floor(min_y / self.cell_size)) + max_cell_y = int(math.floor(max_y / self.cell_size)) + + results = [] + + for cell_x in range(min_cell_x, max_cell_x + 1): + for cell_y in range(min_cell_y, max_cell_y + 1): + cell = (cell_x, cell_y) + results.extend(self.grid.get(cell, [])) + + return results + +# Usage Example +class Unit: + def __init__(self, x, y, team): + self.x = x + self.y = y + self.team = team + self.vision_range = 50 + self.attack_range = 20 + +def game_loop(): + units = [Unit(random() * 1000, random() * 1000, random_team()) + for _ in range(1000)] + + # Cell size = 2× max query radius (vision range) + spatial_grid = SpatialHashGrid(cell_size=100) + + while running: + # Rebuild grid each frame (units move) + spatial_grid.clear() + for unit in units: + spatial_grid.insert(unit) + + # Update units + for unit in units: + # OLD (O(n²)): Check all 1000 units = 1,000,000 checks + # enemies = [u for u in units if u.team != unit.team and distance(u, unit) < vision_range] + + # NEW (O(k)): Check ~10-50 units in nearby cells + nearby = spatial_grid.query_radius(unit.x, unit.y, unit.vision_range) + enemies = [u for u in nearby if u.team != unit.team] + + # Attack enemies in range + for enemy in enemies: + dist_sq = (unit.x - enemy.x)**2 + (unit.y - enemy.y)**2 + if dist_sq <= unit.attack_range**2: + enemy.health -= unit.damage + +# Performance: O(n²) → O(n) +# 1000 units: 1,000,000 checks → ~30,000 checks (nearby cells only) +# Speedup: ~30-50× for vision/attack queries +``` + +### Pattern 2: Quadtree for Clustered Entities + +**When to use**: Entities cluster in specific areas (cities, battlefields) with sparse regions + +```python +class Quadtree: + """ + Adaptive spatial partitioning for clustered entity distributions. + + Best for: Non-uniform distribution, entities cluster in areas + Automatically subdivides dense regions + """ + + class Node: + def __init__(self, x, y, width, height, max_entities=10, max_depth=8): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_entities = max_entities + self.max_depth = max_depth + self.entities = [] + self.children = None # [NW, NE, SW, SE] when subdivided + + def is_leaf(self): + return self.children is None + + def contains(self, entity): + """Check if entity is within this node's bounds""" + return (self.x <= entity.x < self.x + self.width and + self.y <= entity.y < self.y + self.height) + + def subdivide(self): + """Split into 4 quadrants""" + hw = self.width / 2 # half width + hh = self.height / 2 # half height + + # Create 4 children: NW, NE, SW, SE + self.children = [ + Quadtree.Node(self.x, self.y, hw, hh, + self.max_entities, self.max_depth - 1), # NW + Quadtree.Node(self.x + hw, self.y, hw, hh, + self.max_entities, self.max_depth - 1), # NE + Quadtree.Node(self.x, self.y + hh, hw, hh, + self.max_entities, self.max_depth - 1), # SW + Quadtree.Node(self.x + hw, self.y + hh, hw, hh, + self.max_entities, self.max_depth - 1), # SE + ] + + # Move entities to children + for entity in self.entities: + for child in self.children: + if child.contains(entity): + child.insert(entity) + break + + self.entities.clear() + + def insert(self, entity): + """Insert entity into quadtree""" + if not self.contains(entity): + return False + + if self.is_leaf(): + self.entities.append(entity) + + # Subdivide if over capacity and can go deeper + if len(self.entities) > self.max_entities and self.max_depth > 0: + self.subdivide() + else: + # Insert into appropriate child + for child in self.children: + if child.insert(entity): + break + + return True + + def query_radius(self, x, y, radius, results): + """Find entities within radius of (x, y)""" + # Check if search circle intersects this node + closest_x = max(self.x, min(x, self.x + self.width)) + closest_y = max(self.y, min(y, self.y + self.height)) + + dx = x - closest_x + dy = y - closest_y + dist_sq = dx * dx + dy * dy + + if dist_sq > radius * radius: + return # No intersection + + if self.is_leaf(): + # Check entities in this leaf + radius_sq = radius * radius + for entity in self.entities: + dx = entity.x - x + dy = entity.y - y + if dx * dx + dy * dy <= radius_sq: + results.append(entity) + else: + # Recurse into children + for child in self.children: + child.query_radius(x, y, radius, results) + + def __init__(self, world_width, world_height, max_entities=10, max_depth=8): + self.root = Quadtree.Node(0, 0, world_width, world_height, + max_entities, max_depth) + + def insert(self, entity): + self.root.insert(entity) + + def query_radius(self, x, y, radius): + results = [] + self.root.query_radius(x, y, radius, results) + return results + +# Usage +quadtree = Quadtree(world_width=1000, world_height=1000, + max_entities=10, max_depth=8) + +# Insert entities +for unit in units: + quadtree.insert(unit) + +# Query +enemies_nearby = quadtree.query_radius(player.x, player.y, vision_range=50) + +# Performance: O(log n) average query +# Adapts to entity distribution automatically +``` + +### Pattern 3: Distance-Based LOD System + +**Problem**: All entities update at full frequency, wasting CPU on distant entities + +**Solution**: Update frequency based on distance from camera/player + +```python +class LODSystem: + """ + Level-of-detail system with smooth transitions and importance weighting. + + LOD 0: Full detail (near camera, important entities) + LOD 1: Reduced detail (medium distance) + LOD 2: Minimal detail (far distance) + LOD 3: Dormant (very far, culled) + """ + + # LOD configuration + LOD_LEVELS = [ + { + 'name': 'LOD_0_FULL', + 'distance_min': 0, + 'distance_max': 50, + 'update_hz': 60, # Every frame + 'ai_enabled': True, + 'pathfinding': 'full', # Precise A* + 'animation': 'full', # Full skeleton + 'physics': 'full' # Full collision + }, + { + 'name': 'LOD_1_REDUCED', + 'distance_min': 50, + 'distance_max': 100, + 'update_hz': 30, # Every 2 frames + 'ai_enabled': True, + 'pathfinding': 'hierarchical', + 'animation': 'reduced', # 10 bones + 'physics': 'bbox' # Bounding box only + }, + { + 'name': 'LOD_2_MINIMAL', + 'distance_min': 100, + 'distance_max': 200, + 'update_hz': 12, # Every 5 frames + 'ai_enabled': False, # Scripted only + 'pathfinding': 'waypoints', + 'animation': 'static', # Static pose + 'physics': 'none' + }, + { + 'name': 'LOD_3_CULLED', + 'distance_min': 200, + 'distance_max': float('inf'), + 'update_hz': 2, # Every 30 frames + 'ai_enabled': False, + 'pathfinding': 'none', + 'animation': 'none', + 'physics': 'none' + } + ] + + def __init__(self, camera, player): + self.camera = camera + self.player = player + self.frame_count = 0 + + # Hysteresis to prevent LOD thrashing + self.hysteresis = 20 # Units of distance buffer + + def calculate_lod(self, entity): + """ + Calculate LOD level for entity based on importance and distance. + + Priority: + 1. Importance (player-controlled, in combat, selected) + 2. Distance from camera + 3. Screen size + """ + # Important entities always get highest LOD + if self._is_important(entity): + return 0 + + # Distance-based LOD + distance = self._distance_to_camera(entity) + + # Current LOD (for hysteresis) + current_lod = getattr(entity, 'lod_level', 0) + + # Determine LOD level with hysteresis + for i, lod in enumerate(self.LOD_LEVELS): + if i < current_lod: + # Upgrading (closer): Use min distance + if distance <= lod['distance_max'] - self.hysteresis: + return i + else: + # Downgrading (farther): Use max distance + if distance <= lod['distance_max'] + self.hysteresis: + return i + + return len(self.LOD_LEVELS) - 1 + + def _is_important(self, entity): + """Check if entity is important (always highest LOD)""" + return (entity.player_controlled or + entity.selected or + (entity.team == self.player.team and entity.in_combat)) + + def _distance_to_camera(self, entity): + dx = entity.x - self.camera.x + dy = entity.y - self.camera.y + return math.sqrt(dx * dx + dy * dy) + + def should_update(self, entity): + """Check if entity should update this frame""" + lod_level = entity.lod_level + lod_config = self.LOD_LEVELS[lod_level] + update_hz = lod_config['update_hz'] + + if update_hz >= 60: + return True # Every frame + + # Calculate frame interval + frame_interval = 60 // update_hz # 60 FPS baseline + + # Offset by entity ID to spread updates across frames + return (self.frame_count + entity.id) % frame_interval == 0 + + def update(self, entities): + """Update LOD levels and entities""" + self.frame_count += 1 + + # Update LOD levels (cheap, do every frame) + for entity in entities: + entity.lod_level = self.calculate_lod(entity) + + # Update entities based on LOD (expensive, time-sliced) + for entity in entities: + if self.should_update(entity): + lod_config = self.LOD_LEVELS[entity.lod_level] + self._update_entity(entity, lod_config) + + def _update_entity(self, entity, lod_config): + """Update entity according to LOD configuration""" + if lod_config['ai_enabled']: + entity.update_ai() + + if lod_config['pathfinding'] == 'full': + entity.update_pathfinding_full() + elif lod_config['pathfinding'] == 'hierarchical': + entity.update_pathfinding_hierarchical() + elif lod_config['pathfinding'] == 'waypoints': + entity.update_pathfinding_waypoints() + + if lod_config['animation'] != 'none': + entity.update_animation(lod_config['animation']) + + if lod_config['physics'] == 'full': + entity.update_physics_full() + elif lod_config['physics'] == 'bbox': + entity.update_physics_bbox() + +# Usage +lod_system = LODSystem(camera, player) + +def game_loop(): + lod_system.update(units) + # Only entities that should_update() this frame were updated + +# Performance: 1000 units all at LOD 0 → mixed LOD levels +# Typical distribution: 100 LOD0 + 300 LOD1 + 400 LOD2 + 200 LOD3 +# Effective updates: 100 + 150 + 80 + 7 = 337 updates/frame +# Speedup: 1000 → 337 = 3× faster +``` + +### Pattern 4: Time-Sliced Pathfinding with Priority Queue + +**Problem**: 100 path requests × 5ms each = 500ms frame time (2 FPS) + +**Solution**: Process paths over multiple frames with priority (player units first) + +```python +import heapq +import time +from enum import Enum + +class PathPriority(Enum): + """Priority levels for pathfinding requests""" + CRITICAL = 0 # Player-controlled, combat + HIGH = 1 # Player's units + NORMAL = 2 # Visible units + LOW = 3 # Off-screen units + +class PathRequest: + def __init__(self, entity, start, goal, priority): + self.entity = entity + self.start = start + self.goal = goal + self.priority = priority + self.path = None + self.complete = False + self.timestamp = time.time() + +class TimeSlicedPathfinder: + """ + Pathfinding system with frame time budget and priority queue. + + Features: + - 2ms frame budget (stays at 60 FPS) + - Priority queue (important requests first) + - Incremental pathfinding (spread work over frames) + - Request timeout (abandon old requests) + """ + + def __init__(self, budget_ms=2.0, timeout_seconds=5.0): + self.budget = budget_ms / 1000.0 # Convert to seconds + self.timeout = timeout_seconds + self.pending = [] # Priority queue: (priority, request) + self.active_request = None + self.pathfinder = AStarPathfinder() # Your pathfinding implementation + + # Statistics + self.stats = { + 'requests_submitted': 0, + 'requests_completed': 0, + 'requests_timeout': 0, + 'avg_time_to_completion': 0 + } + + def submit_request(self, entity, start, goal, priority=PathPriority.NORMAL): + """Submit pathfinding request with priority""" + request = PathRequest(entity, start, goal, priority) + heapq.heappush(self.pending, (priority.value, request)) + self.stats['requests_submitted'] += 1 + return request + + def update(self, dt): + """ + Process pathfinding requests within frame budget. + + Returns: Number of paths completed this frame + """ + start_time = time.perf_counter() + completed = 0 + + while time.perf_counter() - start_time < self.budget: + # Get next request + if not self.active_request: + if not self.pending: + break # No more work + + priority, request = heapq.heappop(self.pending) + + # Check timeout + if time.time() - request.timestamp > self.timeout: + self.stats['requests_timeout'] += 1 + continue + + self.active_request = request + self.pathfinder.start(request.start, request.goal) + + # Process active request incrementally + # (process up to 100 nodes this frame) + done = self.pathfinder.step(max_nodes=100) + + if done: + # Request complete + self.active_request.path = self.pathfinder.get_path() + self.active_request.complete = True + self.active_request.entity.path = self.active_request.path + + time_to_complete = time.time() - self.active_request.timestamp + self._update_avg_time(time_to_complete) + + self.stats['requests_completed'] += 1 + self.active_request = None + completed += 1 + + return completed + + def _update_avg_time(self, time_to_complete): + """Update moving average of completion time""" + alpha = 0.1 # Smoothing factor + current_avg = self.stats['avg_time_to_completion'] + self.stats['avg_time_to_completion'] = ( + alpha * time_to_complete + (1 - alpha) * current_avg + ) + + def get_stats(self): + """Get performance statistics""" + pending_count = len(self.pending) + (1 if self.active_request else 0) + return { + **self.stats, + 'pending_requests': pending_count, + 'completion_rate': ( + self.stats['requests_completed'] / max(1, self.stats['requests_submitted']) + ) + } + +# Usage +pathfinder = TimeSlicedPathfinder(budget_ms=2.0) + +def game_loop(): + # Submit pathfinding requests + for unit in units_needing_paths: + # Determine priority + if unit.player_controlled: + priority = PathPriority.CRITICAL + elif unit.team == player.team: + priority = PathPriority.HIGH + elif unit.visible: + priority = PathPriority.NORMAL + else: + priority = PathPriority.LOW + + pathfinder.submit_request(unit, unit.pos, unit.target, priority) + + # Process paths (stays within 2ms budget) + paths_completed = pathfinder.update(dt) + + # Every 5 seconds, print stats + if frame_count % 300 == 0: + stats = pathfinder.get_stats() + print(f"Pathfinding: {stats['requests_completed']} complete, " + f"{stats['pending_requests']} pending, " + f"avg time: {stats['avg_time_to_completion']:.2f}s") + +# Performance: +# Without time-slicing: 100 paths × 5ms = 500ms frame (2 FPS) +# With time-slicing: 2ms budget per frame = 60 FPS maintained +# Paths complete over multiple frames, but high-priority paths finish first +``` + +### Pattern 5: LRU Cache with TTL for Pathfinding + +**Problem**: Recalculating same paths repeatedly wastes CPU + +**Solution**: Cache paths with LRU eviction and time-to-live + +```python +import time +from collections import OrderedDict + +class PathCache: + """ + LRU cache with TTL for pathfinding results. + + Features: + - LRU eviction (least recently used) + - TTL expiration (paths become stale) + - Region invalidation (terrain changes) + - Bounded memory (max size) + """ + + def __init__(self, max_size=5000, ttl_seconds=30.0): + self.cache = OrderedDict() # Maintains insertion order for LRU + self.max_size = max_size + self.ttl = ttl_seconds + self.insert_times = {} + + # Statistics + self.stats = { + 'hits': 0, + 'misses': 0, + 'evictions': 0, + 'expirations': 0, + 'invalidations': 0 + } + + def _make_key(self, start, goal): + """Create cache key from start/goal positions""" + # Quantize to grid (allows position variance within cell) + # Cell size = 5 units (units within 5 units share same path) + return ( + round(start[0] / 5) * 5, + round(start[1] / 5) * 5, + round(goal[0] / 5) * 5, + round(goal[1] / 5) * 5 + ) + + def get(self, start, goal): + """ + Get cached path if available and not expired. + + Returns: Path if cached and valid, None otherwise + """ + key = self._make_key(start, goal) + current_time = time.time() + + if key not in self.cache: + self.stats['misses'] += 1 + return None + + # Check TTL + if current_time - self.insert_times[key] > self.ttl: + # Expired + del self.cache[key] + del self.insert_times[key] + self.stats['expirations'] += 1 + self.stats['misses'] += 1 + return None + + # Cache hit - move to end (most recently used) + self.cache.move_to_end(key) + self.stats['hits'] += 1 + return self.cache[key] + + def put(self, start, goal, path): + """Store path in cache""" + key = self._make_key(start, goal) + current_time = time.time() + + # Evict if at capacity (LRU) + if len(self.cache) >= self.max_size and key not in self.cache: + # Remove oldest (first item in OrderedDict) + oldest_key = next(iter(self.cache)) + del self.cache[oldest_key] + del self.insert_times[oldest_key] + self.stats['evictions'] += 1 + + # Store path + self.cache[key] = path + self.insert_times[key] = current_time + + # Move to end (most recently used) + self.cache.move_to_end(key) + + def invalidate_region(self, x, y, radius): + """ + Invalidate all cached paths in region. + + Call when terrain changes (building placed, wall destroyed, etc.) + """ + radius_sq = radius * radius + keys_to_remove = [] + + for key in self.cache: + start_x, start_y, goal_x, goal_y = key + + # Check if start or goal in affected region + dx_start = start_x - x + dy_start = start_y - y + dx_goal = goal_x - x + dy_goal = goal_y - y + + if (dx_start * dx_start + dy_start * dy_start <= radius_sq or + dx_goal * dx_goal + dy_goal * dy_goal <= radius_sq): + keys_to_remove.append(key) + + for key in keys_to_remove: + del self.cache[key] + del self.insert_times[key] + self.stats['invalidations'] += 1 + + def get_hit_rate(self): + """Calculate cache hit rate""" + total = self.stats['hits'] + self.stats['misses'] + if total == 0: + return 0.0 + return self.stats['hits'] / total + + def get_stats(self): + """Get cache statistics""" + return { + **self.stats, + 'size': len(self.cache), + 'hit_rate': self.get_hit_rate() + } + +# Usage +path_cache = PathCache(max_size=5000, ttl_seconds=30.0) + +def find_path(start, goal): + # Try cache first + cached_path = path_cache.get(start, goal) + if cached_path: + return cached_path # Cache hit! + + # Cache miss - calculate path + path = expensive_pathfinding(start, goal) + path_cache.put(start, goal, path) + return path + +# Invalidate when terrain changes +def on_building_placed(building): + # Invalidate paths near building + path_cache.invalidate_region(building.x, building.y, radius=100) + +# Print stats periodically +def print_cache_stats(): + stats = path_cache.get_stats() + print(f"Path Cache: {stats['size']}/{path_cache.max_size} entries, " + f"hit rate: {stats['hit_rate']:.1%}, " + f"{stats['hits']} hits, {stats['misses']} misses") + +# Performance: +# 60% hit rate: Only 40% of requests calculate = 2.5× faster +# 80% hit rate: Only 20% of requests calculate = 5× faster +``` + +### Pattern 6: Job System for Parallel Work + +**When to use**: Native code (C++/Rust) with embarrassingly parallel work + +```cpp +#include +#include +#include +#include +#include +#include + +/** + * Job system for data-parallel work. + * + * Features: + * - Worker thread pool + * - Lock-free job submission (mostly) + * - Wait-for-completion + * - No shared mutable state (data parallelism) + */ +class JobSystem { +public: + using Job = std::function; + + JobSystem(int num_workers = std::thread::hardware_concurrency()) { + workers.reserve(num_workers); + + for (int i = 0; i < num_workers; ++i) { + workers.emplace_back([this]() { this->worker_loop(); }); + } + } + + ~JobSystem() { + { + std::unique_lock lock(queue_mutex); + shutdown = true; + } + queue_cv.notify_all(); + + for (auto& worker : workers) { + worker.join(); + } + } + + // Submit single job + void submit(Job job) { + { + std::unique_lock lock(queue_mutex); + job_queue.push(std::move(job)); + } + queue_cv.notify_one(); + } + + // Submit batch of jobs and wait for all to complete + void submit_batch_and_wait(const std::vector& jobs) { + std::atomic remaining{static_cast(jobs.size())}; + std::mutex wait_mutex; + std::condition_variable wait_cv; + + for (const auto& job : jobs) { + submit([&, job]() { + job(); + + if (--remaining == 0) { + wait_cv.notify_one(); + } + }); + } + + // Wait for all jobs to complete + std::unique_lock lock(wait_mutex); + wait_cv.wait(lock, [&]() { return remaining == 0; }); + } + +private: + void worker_loop() { + while (true) { + Job job; + + { + std::unique_lock lock(queue_mutex); + queue_cv.wait(lock, [this]() { + return !job_queue.empty() || shutdown; + }); + + if (shutdown && job_queue.empty()) { + return; + } + + job = std::move(job_queue.front()); + job_queue.pop(); + } + + job(); + } + } + + std::vector workers; + std::queue job_queue; + std::mutex queue_mutex; + std::condition_variable queue_cv; + bool shutdown = false; +}; + +// Usage Example: Parallel position updates +struct Unit { + float x, y; + float vx, vy; + + void update(float dt) { + x += vx * dt; + y += vy * dt; + } +}; + +void update_units_parallel(std::vector& units, float dt, JobSystem& job_system) { + const int num_workers = 8; + const int batch_size = units.size() / num_workers; + + std::vector jobs; + + for (int worker_id = 0; worker_id < num_workers; ++worker_id) { + int start = worker_id * batch_size; + int end = (worker_id == num_workers - 1) ? units.size() : start + batch_size; + + jobs.push_back([&units, dt, start, end]() { + // Each worker updates exclusive slice (no locks needed) + for (int i = start; i < end; ++i) { + units[i].update(dt); + } + }); + } + + job_system.submit_batch_and_wait(jobs); +} + +// Performance: 4 cores = 2-3× speedup (accounting for overhead) +``` + + +## Common Pitfalls + +### Pitfall 1: Premature Optimization (Most Common!) + +**Symptoms**: +- Jumping to complex solutions (multithreading) before measuring bottleneck +- Micro-optimizing (sqrt → squared distance) without profiling +- Optimizing code that's 1% of frame time + +**Why it fails**: +- You optimize the wrong thing (80% of time elsewhere) +- Complex solutions add bugs without benefit +- Time wasted that could go to real bottleneck + +**Example**: +```python +# BAD: Premature micro-optimization +# Replaced sqrt with squared distance (saves 0.1ms) +# But vision checks are only 1% of frame time! +dist_sq = dx*dx + dy*dy +if dist_sq < range_sq: # Micro-optimization + # ... + +# GOOD: Profile first, found pathfinding is 80% of frame time +# Added path caching (saves 40ms!) +cached_path = path_cache.get(start, goal) +if cached_path: + return cached_path +``` + +**Solution**: +1. ✅ **Profile FIRST** - measure where time is actually spent +2. ✅ **Focus on top bottleneck** (80/20 rule) +3. ✅ **Measure improvement** - validate optimization helped +4. ✅ **Repeat** - find next bottleneck + +**Quote**: "Premature optimization is the root of all evil" - Donald Knuth + +### Pitfall 2: LOD Popping (Visual Artifacts) + +**Symptoms**: +- Units suddenly appear/disappear at LOD boundaries +- Animation quality jumps (smooth → jerky) +- Players notice "fake" LOD transitions + +**Why it fails**: +- No hysteresis: Entity at 99-101 units flip-flops between LOD 0/1 every frame +- Instant transitions: LOD 0 → LOD 3 in one frame (jarring) +- Distance-only: Ignores importance (player's units should always be high detail) + +**Example**: +```python +# BAD: No hysteresis (causes popping) +if distance < 100: + lod = 0 +else: + lod = 1 +# Entity at 99.5 units: LOD 0 +# Entity moves to 100.5 units: LOD 1 +# Entity moves to 99.5 units: LOD 0 (flicker!) + +# GOOD: Hysteresis + importance + blend +if is_important(entity): + lod = 0 # Always full detail for player units +elif distance < 90: + lod = 0 # Upgrade at 90 +elif distance > 110: + lod = 1 # Downgrade at 110 +# else: keep current LOD +# 20-unit buffer prevents thrashing + +# Blend between LOD levels over 0.5 seconds +blend_factor = (time.time() - lod_transition_start) / 0.5 +``` + +**Solution**: +1. ✅ **Hysteresis** - different thresholds for upgrade (90) vs downgrade (110) +2. ✅ **Importance weighting** - player units, selected units always high LOD +3. ✅ **Blend transitions** - cross-fade over 0.5-1 second +4. ✅ **Time delay** - wait N seconds before downgrading LOD + +### Pitfall 3: Thread Contention and Race Conditions + +**Symptoms**: +- Crashes with "list modified during iteration" +- Nondeterministic behavior (works sometimes) +- Slower with multithreading than without (due to locking) + +**Why it fails**: +- Multiple threads read/write shared mutable state (data race) +- Excessive locking serializes code (defeats parallelism) +- False sharing - adjacent data on same cache line thrashes + +**Example**: +```python +# BAD: Race condition (shared mutable list) +def update_unit_threaded(unit, all_units): + # Thread 1 reads all_units + # Thread 2 modifies all_units (adds/removes unit) + # Thread 1 crashes: "list changed during iteration" + for other in all_units: + if collides(unit, other): + all_units.remove(other) # RACE! + +# BAD: Excessive locking (serialized) +lock = threading.Lock() + +def update_unit(unit): + with lock: # Only 1 thread works at a time! + unit.update() + +# GOOD: Data parallelism (no shared mutable state) +def update_units_parallel(units, num_workers=4): + batch_size = len(units) // num_workers + + def update_batch(start, end): + # Exclusive ownership - no locks needed + for i in range(start, end): + units[i].update() # Only modifies units[i] + + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = [] + for worker_id in range(num_workers): + start = worker_id * batch_size + end = start + batch_size if worker_id < num_workers - 1 else len(units) + futures.append(executor.submit(update_batch, start, end)) + + # Wait for all + for future in futures: + future.result() +``` + +**Solution**: +1. ✅ **Avoid shared mutable state** - each thread owns exclusive data +2. ✅ **Read-only sharing** - threads can read shared data if no writes +3. ✅ **Message passing** - communicate via queues instead of shared memory +4. ✅ **Lock-free algorithms** - atomic operations, compare-and-swap +5. ✅ **Test with thread sanitizer** - detects data races + +### Pitfall 4: Cache Invalidation Bugs + +**Symptoms**: +- Units walk through walls (stale paths cached) +- Memory leak (cache grows unbounded) +- Crashes after long play sessions (out of memory) + +**Why it fails**: +- No invalidation: Cache never updates when terrain changes +- No TTL: Old paths stay forever, become invalid +- No eviction: Cache grows until memory exhausted + +**Example**: +```python +# BAD: No invalidation, no TTL, unbounded growth +cache = {} + +def get_path(start, goal): + key = (start, goal) + if key in cache: + return cache[key] # May be stale! + + path = pathfind(start, goal) + cache[key] = path # Cache grows forever! + return path + +# Building placed, but cached paths not invalidated +def place_building(x, y): + buildings.append(Building(x, y)) + # BUG: Paths through this area still cached! + +# GOOD: LRU + TTL + invalidation +cache = PathCache(max_size=5000, ttl_seconds=30.0) + +def get_path(start, goal): + cached = cache.get(start, goal) + if cached: + return cached + + path = pathfind(start, goal) + cache.put(start, goal, path) + return path + +def place_building(x, y): + buildings.append(Building(x, y)) + cache.invalidate_region(x, y, radius=100) # Clear affected paths +``` + +**Solution**: +1. ✅ **TTL (time-to-live)** - expire entries after N seconds +2. ✅ **Event-based invalidation** - clear cache when terrain changes +3. ✅ **LRU eviction** - remove least recently used when full +4. ✅ **Bounded size** - set max_size to prevent unbounded growth + +### Pitfall 5: Forgetting to Rebuild Spatial Grid + +**Symptoms**: +- Units see enemies that are no longer there +- Collision detection misses fast-moving objects +- Query results are stale (from previous frame) + +**Why it fails**: +- Entities move every frame, but grid not rebuilt +- Grid contains stale positions + +**Example**: +```python +# BAD: Grid built once, never updated +spatial_grid = SpatialHashGrid(cell_size=100) +for unit in units: + spatial_grid.insert(unit) + +def game_loop(): + # Units move + for unit in units: + unit.x += unit.vx * dt + unit.y += unit.vy * dt + + # Query stale grid (positions from frame 0!) + enemies = spatial_grid.query_radius(player.x, player.y, 50) + +# GOOD: Rebuild grid every frame +def game_loop(): + # Move units + for unit in units: + unit.x += unit.vx * dt + unit.y += unit.vy * dt + + # Rebuild spatial grid (fast: O(n)) + spatial_grid.clear() + for unit in units: + spatial_grid.insert(unit) + + # Query with current positions + enemies = spatial_grid.query_radius(player.x, player.y, 50) +``` + +**Solution**: +1. ✅ **Rebuild every frame** - spatial_grid.clear() + insert all entities +2. ✅ **Or use dynamic structure** - quadtree with update() method +3. ✅ **Profile rebuild cost** - should be < 1ms for 1000 entities + +### Pitfall 6: Optimization Without Validation + +**Symptoms**: +- "Optimized" code runs slower +- New bottleneck created elsewhere +- Unsure if optimization helped + +**Why it fails**: +- No before/after measurements +- Optimization moved bottleneck to different system +- Assumptions about cost were wrong + +**Example**: +```python +# BAD: No measurement +def optimize_pathfinding(): + # Made some changes... + # Hope it's faster? + pass + +# GOOD: Measure before and after +def optimize_pathfinding(): + # Measure baseline + start = time.perf_counter() + for i in range(100): + path = pathfind(start, goal) + baseline_ms = (time.perf_counter() - start) * 1000 + print(f"Baseline: {baseline_ms:.2f}ms for 100 paths") + + # Apply optimization... + add_path_caching() + + # Measure improvement + start = time.perf_counter() + for i in range(100): + path = pathfind(start, goal) + optimized_ms = (time.perf_counter() - start) * 1000 + print(f"Optimized: {optimized_ms:.2f}ms for 100 paths") + + speedup = baseline_ms / optimized_ms + print(f"Speedup: {speedup:.1f}×") + + # Baseline: 500ms for 100 paths + # Optimized: 200ms for 100 paths + # Speedup: 2.5× +``` + +**Solution**: +1. ✅ **Measure baseline** before optimization +2. ✅ **Measure improvement** after optimization +3. ✅ **Calculate speedup** - validate it helped +4. ✅ **Re-profile** - check for new bottlenecks +5. ✅ **Regression test** - ensure gameplay still works + +### Pitfall 7: Ignoring Amdahl's Law (Diminishing Returns) + +**Concept**: Speedup limited by serial portion of code + +**Amdahl's Law**: `Speedup = 1 / ((1 - P) + P/N)` +- P = portion that can be parallelized (e.g., 0.75 = 75%) +- N = number of cores (e.g., 4) + +**Example**: +- 75% of code parallelizable, 4 cores +- Speedup = 1 / ((1 - 0.75) + 0.75/4) = 1 / (0.25 + 0.1875) = 2.29× +- **Not 4×!** Serial portion limits speedup + +**Why it matters**: +- Multithreading has diminishing returns +- Focus on parallelizing largest portions first +- Some tasks can't be parallelized (Amdahl's law ceiling) + +**Solution**: +1. ✅ **Parallelize largest bottleneck** first (maximize P) +2. ✅ **Set realistic expectations** (2-3× on 4 cores, not 4×) +3. ✅ **Measure actual speedup** - compare to theoretical maximum + +### Pitfall 8: Sorting Every Frame (Expensive!) + +**Symptoms**: +- 3-5ms spent sorting units by distance +- Sorting is top function in profiler + +**Why it fails**: +- O(n log n) sort is expensive for large N +- Entity distances change slowly (don't need exact sort every frame) + +**Example**: +```python +# BAD: Full sort every frame +def update(): + # O(n log n) = 1000 × log(1000) ≈ 10,000 operations + units_sorted = sorted(units, key=lambda u: distance_to_camera(u)) + + # Update closest units + for unit in units_sorted[:100]: + unit.update() + +# GOOD: Sort every N frames, or use approximate sort +def update(): + # Re-sort every 10 frames only + if frame_count % 10 == 0: + global units_sorted + units_sorted = sorted(units, key=lambda u: distance_to_camera(u)) + + # Use slightly stale sort (good enough!) + for unit in units_sorted[:100]: + unit.update() + +# BETTER: Use spatial partitioning (no sorting needed!) +def update(): + # Query entities near camera (already sorted by distance) + nearby_units = spatial_grid.query_radius(camera.x, camera.y, radius=200) + + # Update nearby units + for unit in nearby_units: + unit.update() +``` + +**Solution**: +1. ✅ **Sort less frequently** - every 5-10 frames is fine +2. ✅ **Approximate sort** - bucketing instead of exact sort +3. ✅ **Spatial queries** - avoid sorting entirely (use grid/quadtree) + + +## Real-World Examples + +### Example 1: Unity DOTS (Data-Oriented Technology Stack) + +**What it is**: Unity's high-performance ECS (Entity Component System) architecture + +**Key optimizations**: +1. **Struct of Arrays (SoA)** - Components stored in contiguous arrays + - Traditional: `List` with components scattered in memory + - DOTS: `NativeArray`, `NativeArray` - cache-friendly + - Result: 1.5-3× faster for batch operations + +2. **Job System** - Data parallelism across CPU cores + - Each job processes exclusive slice of entities + - No locks (data ownership model) + - Result: 2-4× speedup on 4-8 core CPUs + +3. **Burst Compiler** - LLVM-based code generation + - Generates SIMD instructions (AVX2, SSE) + - Removes bounds checks, optimizes math + - Result: 2-10× faster than standard C# + +**Performance**: 10,000 entities at 60 FPS (vs 1,000 in traditional Unity) + +**When to use**: +- ✅ 1000+ entities needing updates +- ✅ Batch operations (position updates, physics, AI) +- ✅ Performance-critical simulations + +**When NOT to use**: +- ❌ Small entity counts (< 100) +- ❌ Gameplay prototyping (ECS is complex) +- ❌ Unique entities with lots of one-off logic + +### Example 2: Supreme Commander (RTS with 1000+ Units) + +**Challenge**: Support 1000+ units in RTS battles at 30-60 FPS + +**Optimizations**: +1. **Flow Fields for Pathfinding** + - Pre-compute direction field from goal + - Each unit follows field (O(1) per unit) + - Alternative to A* per unit (O(n log n) each) + - Result: 100× faster pathfinding for groups + +2. **LOD for Unit AI** + - LOD 0 (< 50 units from camera): Full behavior tree + - LOD 1 (50-100 units): Simplified FSM + - LOD 2 (100+ units): Scripted behavior + - Result: 3-5× fewer AI updates per frame + +3. **Spatial Partitioning for Weapons** + - Grid-based broad-phase for weapon targeting + - Only check units in weapon range cells + - Result: O(n²) → O(n) for combat calculations + +4. **Time-Sliced Sim** + - Economy updates: Every 10 frames + - Unit production: Every 5 frames + - Visual effects: Based on distance LOD + - Result: Consistent frame rate under load + +**Performance**: 1000 units at 30 FPS, 500 units at 60 FPS + +**Lessons**: +- Flow fields > A* for large unit groups +- LOD critical for maintaining frame rate at scale +- Spatial partitioning is non-negotiable for 1000+ units + +### Example 3: Total War (20,000+ Soldiers in Battles) + +**Challenge**: Render and simulate 20,000 individual soldiers at 30-60 FPS + +**Optimizations**: +1. **Hierarchical LOD** + - LOD 0 (< 20m): Full skeleton, detailed model + - LOD 1 (20-50m): Reduced skeleton, simpler model + - LOD 2 (50-100m): Impostor (textured quad) + - LOD 3 (100m+): Single pixel or culled + - Result: 10× fewer vertices rendered + +2. **Formation-Based AI** + - Units in formation share single pathfinding result + - Individual units offset from formation center + - Result: 100× fewer pathfinding calculations + +3. **Batched Rendering** + - Instanced rendering for identical soldiers + - 1 draw call for 100 soldiers (vs 100 draw calls) + - Result: 10× fewer draw calls + +4. **Simplified Physics** + - Full physics for nearby units (< 20m) + - Ragdolls for deaths near camera + - Simplified collision for distant units + - Result: 5× fewer physics calculations + +**Performance**: 20,000 units at 30-60 FPS (depending on settings) + +**Lessons**: +- Visual LOD as important as simulation LOD +- Formation-based AI avoids redundant pathfinding +- Instanced rendering critical for large unit counts + +### Example 4: Cities Skylines (Traffic Simulation) + +**Challenge**: Simulate 10,000+ vehicles with realistic traffic at 30 FPS + +**Optimizations**: +1. **Hierarchical Pathfinding** + - Highway network → arterial roads → local streets + - Pre-compute high-level paths, refine locally + - Result: 20× faster pathfinding for long routes + +2. **Path Caching** + - Common routes cached (home → work, work → home) + - 60-80% cache hit rate + - Result: 2.5-5× fewer pathfinding calculations + +3. **Dynamic Cost Adjustment** + - Road segments track vehicle density + - Congested roads have higher pathfinding cost + - Vehicles reroute around congestion + - Result: Emergent traffic patterns + +4. **Despawn Distant Vehicles** + - Vehicles > 500m from camera despawned + - Statistics tracked, respawn when relevant + - Result: Effective vehicle count reduced 50% + +**Performance**: 10,000 active vehicles at 30 FPS + +**Lessons**: +- Hierarchical pathfinding essential for city-scale maps +- Path caching provides huge wins (60%+ hit rate common) +- Despawning off-screen entities maintains performance + +### Example 5: Factorio (Mega-Factory Optimization) + +**Challenge**: Simulate 100,000+ entities (belts, inserters, assemblers) at 60 FPS + +**Optimizations**: +1. **Update Skipping** + - Idle machines don't update (no input/output) + - Active set typically 10-20% of total entities + - Result: 5-10× fewer updates per tick + +2. **Chunk-Based Simulation** + - World divided into 32×32 tile chunks + - Inactive chunks (no player nearby) update less often + - Result: Effective world size reduced 80% + +3. **Belt Optimization** + - Items on belts compressed into contiguous arrays + - Lane-based updates (not per-item) + - Result: 10× faster belt simulation + +4. **Electrical Network Caching** + - Power grid solved once, cached until topology changes + - Only recalculate when grid modified + - Result: 100× fewer electrical calculations + +**Performance**: 60 FPS with 100,000+ entities (in optimized factories) + +**Lessons**: +- Update skipping (sleeping entities) provides huge wins +- Chunk-based simulation scales to massive worlds +- Cache static calculations (power grid, fluid networks) + + +## Cross-References + +### Within Bravos/Simulation-Tactics + +**This skill applies to ALL other simulation skills**: + +- **traffic-and-pathfinding** ← Optimize pathfinding with caching, time-slicing +- **ai-and-agent-simulation** ← LOD for AI, time-sliced behavior trees +- **physics-simulation-patterns** ← Spatial partitioning for collision, broad-phase +- **ecosystem-simulation** ← LOD for distant populations, time-sliced updates +- **weather-and-time** ← Particle budgets, LOD for effects +- **economic-simulation-patterns** ← Time-slicing for economy updates + +**Related skills in this skillpack**: +- **spatial-partitioning** (planned) - Deep dive into quadtrees, octrees, grids +- **ecs-architecture** (planned) - Data-oriented design, component systems + +### External Skillpacks + +**Yzmir/Performance-Optimization** (if exists): +- Profiling tools and methodology +- Memory optimization (pooling, allocators) +- Cache optimization (data layouts) + +**Yzmir/Algorithms-and-Data-Structures** (if exists): +- Spatial data structures (quadtree, k-d tree, BVH) +- Priority queues (for time-slicing) +- LRU cache implementation + +**Axiom/Game-Engine-Patterns** (if exists): +- Update loop patterns +- Frame time management +- Object pooling + + +## Testing Checklist + +Use this checklist to verify optimization is complete and correct: + +### 1. Profiling + +- [ ] Captured baseline performance (frame time, FPS) +- [ ] Identified top 3-5 bottlenecks (80% of time) +- [ ] Understood WHY each bottleneck is slow (algorithm, data, cache) +- [ ] Documented baseline metrics for comparison + +### 2. Algorithmic Optimization + +- [ ] Checked for O(n²) algorithms (proximity queries, collisions) +- [ ] Applied spatial partitioning where appropriate (grid, quadtree) +- [ ] Validated spatial queries return correct results +- [ ] Measured improvement (should be 10-100×) + +### 3. Level of Detail (LOD) + +- [ ] Defined LOD levels (typically 4: full, reduced, minimal, culled) +- [ ] Implemented distance-based LOD assignment +- [ ] Added importance weighting (player units, selected units) +- [ ] Implemented hysteresis to prevent LOD thrashing +- [ ] Verified no visual popping artifacts +- [ ] Measured improvement (should be 2-10×) + +### 4. Time-Slicing + +- [ ] Set frame time budget per system (e.g., 2ms for pathfinding) +- [ ] Implemented priority queue (important work first) +- [ ] Verified budget is respected (doesn't exceed limit) +- [ ] Checked that high-priority work completes quickly +- [ ] Measured improvement (should be 2-5×) + +### 5. Caching + +- [ ] Identified redundant calculations to cache +- [ ] Implemented cache with LRU eviction +- [ ] Added TTL (time-to-live) expiration +- [ ] Implemented invalidation triggers (terrain changes, etc.) +- [ ] Verified cache hit rate (aim for 60-80%) +- [ ] Checked no stale data bugs (units walking through walls) +- [ ] Measured improvement (should be 2-10×) + +### 6. Data-Oriented Design (if applicable) + +- [ ] Identified batch operations on many entities +- [ ] Converted AoS → SoA for hot data +- [ ] Verified memory layout is cache-friendly +- [ ] Measured improvement (should be 1.5-3×) + +### 7. Multithreading (if needed) + +- [ ] Verified all simpler optimizations done first +- [ ] Identified embarrassingly parallel work +- [ ] Implemented job system or data parallelism +- [ ] Verified no race conditions (test with thread sanitizer) +- [ ] Checked performance gain justifies complexity +- [ ] Measured improvement (should be 1.5-4×) + +### 8. Validation + +- [ ] Met target frame rate (60 FPS or 30 FPS) +- [ ] Verified no gameplay regressions (units behave correctly) +- [ ] Checked no visual artifacts (LOD popping, etc.) +- [ ] Tested at target entity count (e.g., 1000 units) +- [ ] Tested edge cases (10,000 units, worst-case scenarios) +- [ ] Documented final performance metrics +- [ ] Calculated total speedup (baseline → optimized) + +### 9. Before/After Comparison + +| Metric | Before | After | Improvement | +|--------|--------|-------|-------------| +| **Frame Time** | ___ms | ___ms | ___× faster | +| **FPS** | ___ | ___ | ___ | +| **Bottleneck System Time** | ___ms | ___ms | ___× faster | +| **Entity Count (target FPS)** | ___ | ___ | ___× more | +| **Memory Usage** | ___MB | ___MB | ___ | + +### 10. Regression Tests + +- [ ] Units still path correctly (no walking through walls) +- [ ] AI behavior unchanged (same decisions) +- [ ] Combat calculations correct (same damage) +- [ ] No crashes or exceptions +- [ ] No memory leaks (long play session test) +- [ ] Deterministic results (same input → same output) + + +**Remember**: +1. **Profile FIRST** - measure before guessing +2. **Algorithmic optimization** provides biggest wins (10-100×) +3. **LOD and time-slicing** are essential for 1000+ entities +4. **Multithreading is LAST resort** - complexity cost is high +5. **Validate improvement** - measure before/after, check for regressions + +**Success criteria**: Target frame rate achieved (60 FPS) with desired entity count (1000+) and no gameplay compromises. diff --git a/skills/using-simulation-tactics/physics-simulation-patterns.md b/skills/using-simulation-tactics/physics-simulation-patterns.md new file mode 100644 index 0000000..8bce6f4 --- /dev/null +++ b/skills/using-simulation-tactics/physics-simulation-patterns.md @@ -0,0 +1,2398 @@ + +# Physics Simulation Patterns + +## Description +Master rigid body dynamics, soft body simulation, cloth, fluids, and vehicle physics for real-time game engines. Apply fixed timestep integration, continuous collision detection, and deterministic simulation patterns to avoid physics explosions, tunneling, and multiplayer desyncs. + +## When to Use This Skill +Use this skill when implementing or debugging: +- Vehicle physics (cars, boats, aircraft) +- Character physics (ragdolls, dynamic movement) +- Destructible environments (debris, particles) +- Cloth and soft body simulation +- Fluid dynamics for games +- Multiplayer physics synchronization +- Any real-time physics simulation requiring stability and determinism + +Do NOT use this skill for: +- Basic kinematic movement (simple position/velocity updates) +- Pure animation systems without physics +- UI animations or tweens +- Turn-based games without real-time physics + + +## Quick Start (Time-Constrained Implementation) + +If you need working physics quickly (< 4 hours), follow this priority order: + +**CRITICAL (Never Skip)**: +1. **Fixed timestep**: Use engine's fixed update (Unity: `FixedUpdate()`, Unreal: auto-handled) +2. **Use engine built-ins**: Unity `WheelCollider`, Unreal `WheeledVehicleMovementComponent` +3. **Enable CCD**: For objects faster than 20 m/s (prevents tunneling through walls) +4. **Semi-implicit integration**: Engine default (don't change it) + +**IMPORTANT (Strongly Recommended)**: +5. Lower center of mass for vehicles (improves stability) +6. Test at different frame rates (30 FPS and 144 FPS should behave identically) +7. Add velocity clamping for safety (prevent physics explosions) + +**CAN DEFER** (Optimize Later): +- Custom tire friction models (use engine defaults first) +- Advanced aerodynamics and downforce +- Detailed damage and deformation systems +- Performance optimizations (if meeting target frame rate) + +**Example - Unity Vehicle in 30 Minutes**: +```csharp +// 1. Add WheelColliders to wheel positions +// 2. Configure in FixedUpdate(): +void FixedUpdate() { // ← Fixed timestep automatically + wheelFL.motorTorque = Input.GetAxis("Vertical") * 1500f; + wheelFR.motorTorque = Input.GetAxis("Vertical") * 1500f; + wheelFL.steerAngle = Input.GetAxis("Horizontal") * 30f; + wheelFR.steerAngle = Input.GetAxis("Horizontal") * 30f; +} + +// 3. Enable CCD on Rigidbody: +GetComponent().collisionDetectionMode = CollisionDetectionMode.Continuous; + +// 4. Lower center of mass: +GetComponent().centerOfMass = new Vector3(0, -0.5f, 0); +``` + +This gives you functional vehicle physics. Refine later based on feel and performance. + + +## Core Concepts + +### 1. Physics Integration Methods + +Physics integration updates object positions based on forces. The method choice determines stability, accuracy, and performance. + +**Euler Integration** (Explicit/Forward Euler): +```python +# Simple but UNSTABLE for most game physics +velocity += acceleration * dt +position += velocity * dt + +# Problem: Energy accumulation +# At high speeds or large dt, objects gain energy and "explode" +``` + +**Semi-Implicit Euler** (Symplectic Euler): +```python +# Better stability - use for most game physics +velocity += acceleration * dt +position += velocity * dt # Uses UPDATED velocity + +# Advantage: Conserves energy better, stable for games +# This is what Unity/Unreal use internally +``` + +**Verlet Integration**: +```python +# Position-based, good for constraints +new_position = 2 * position - previous_position + acceleration * dt * dt +previous_position = position +position = new_position + +# Advantage: Stable, easy constraints, no explicit velocity +# Used in: Cloth simulation, rope physics, soft bodies +``` + +**Runge-Kutta (RK4)**: +```python +# High accuracy but expensive +# 4 function evaluations per timestep +k1 = f(t, y) +k2 = f(t + dt/2, y + dt/2 * k1) +k3 = f(t + dt/2, y + dt/2 * k2) +k4 = f(t + dt, y + dt * k3) +y_next = y + dt/6 * (k1 + 2*k2 + 2*k3 + k4) + +# Use ONLY when: Accuracy critical, performance not (orbital mechanics, space sims) +# Most games: Semi-implicit Euler is better trade-off +``` + +**Decision Framework**: +- **Rigid bodies (vehicles, debris)**: Semi-Implicit Euler + Fixed Timestep +- **Cloth/rope/chains**: Verlet Integration + Position-based constraints +- **Orbital mechanics**: RK4 or higher-order methods +- **Never use**: Explicit Euler (unstable) + +### 2. Fixed Timestep vs Variable Timestep + +**The Problem**: +Variable timestep (using raw frame delta time) causes: +- Frame-rate dependent physics +- Instability at low frame rates +- Non-determinism (different results on different machines) +- Multiplayer desyncs + +**The Solution: Fixed Timestep with Accumulator**: +```python +# ALWAYS use this pattern for game physics +FIXED_TIMESTEP = 1.0 / 60.0 # 60 Hz physics (16.67ms) +accumulator = 0.0 + +def game_loop(): + global accumulator + frame_time = get_delta_time() + + # Clamp to prevent spiral of death + if frame_time > 0.25: + frame_time = 0.25 # Max 250ms (4 FPS minimum) + + accumulator += frame_time + + # Run physics in fixed steps + while accumulator >= FIXED_TIMESTEP: + physics_update(FIXED_TIMESTEP) # Always same dt + accumulator -= FIXED_TIMESTEP + + # Interpolate rendering between physics states + alpha = accumulator / FIXED_TIMESTEP + render_interpolated(alpha) + +def render_interpolated(alpha): + # Smooth visuals between physics steps + interpolated_pos = previous_pos + (current_pos - previous_pos) * alpha + draw_at_position(interpolated_pos) +``` + +**Why This Works**: +- Physics always runs at 60 Hz (consistent) +- Fast machines: Multiple render frames per physics step +- Slow machines: Multiple physics steps per render frame +- Deterministic: Same inputs produce same outputs +- Interpolation: Smooth visuals even at low frame rates + +**Common Mistake - Variable Timestep Scaling**: +```python +# ❌ WRONG - Still frame-rate dependent +dt = get_delta_time() +velocity += acceleration * dt * 60 # "Scale to 60 FPS" + +# Problem: Integration errors still vary with dt +# Physics behaves differently at different frame rates +``` + +**Unity Example**: +```csharp +// Unity provides this via FixedUpdate() +void FixedUpdate() { + // Always runs at Time.fixedDeltaTime (default 0.02 = 50 Hz) + // Use for all physics operations + rb.AddForce(force); +} + +void Update() { + // Variable timestep - use for input, rendering + // NEVER use for physics calculations +} +``` + +### 3. Continuous Collision Detection (CCD) + +**The Problem: Tunneling**: +``` +Frame 1: [Bullet] |Wall| +Frame 2: |Wall| [Bullet] + ^ Bullet passed through wall between frames! +``` + +At high velocities, discrete collision checks miss collisions. For a 200 mph car (89 m/s) at 60 FPS, the car moves 1.48 meters per frame - can easily phase through walls. + +**Solution 1: Conservative Advancement**: +```python +def conservative_advancement(start_pos, end_pos, obstacles): + """Move in small steps until collision""" + current_pos = start_pos + direction = (end_pos - start_pos).normalized() + remaining_distance = (end_pos - start_pos).length() + + while remaining_distance > 0: + # Find distance to nearest obstacle + safe_distance = min_distance_to_obstacles(current_pos, obstacles) + + # Move by safe distance (slightly less for safety margin) + step = min(safe_distance * 0.9, remaining_distance) + current_pos += direction * step + remaining_distance -= step + + if safe_distance < EPSILON: + return current_pos, True # Collision detected + + return current_pos, False +``` + +**Solution 2: Swept Collision Detection**: +```python +def swept_sphere_vs_plane(sphere_center, sphere_radius, velocity, plane): + """Check collision over movement path""" + # Ray from sphere center along velocity + ray_start = sphere_center - plane.normal * sphere_radius + ray_direction = velocity.normalized() + ray_length = velocity.length() + + # Intersect ray with plane + t = ray_plane_intersection(ray_start, ray_direction, plane) + + if 0 <= t <= ray_length: + # Collision occurs during this frame + collision_time = t / ray_length # 0 to 1 + collision_point = ray_start + ray_direction * t + return True, collision_time, collision_point + + return False, None, None +``` + +**Solution 3: Speculative Contacts** (Modern Engines): +```python +def speculative_contacts(body, dt): + """Predict and prevent tunneling before it happens""" + # Expand collision shape based on velocity + velocity_magnitude = body.velocity.length() + expansion = velocity_magnitude * dt + + # Use expanded AABB for collision detection + expanded_bounds = body.bounds.expand(expansion) + + # Check collisions with expanded bounds + contacts = check_collisions(expanded_bounds) + + # Apply contact constraints to prevent penetration + for contact in contacts: + apply_contact_constraint(body, contact, dt) +``` + +**When to Use CCD**: +- **Always**: Bullets, projectiles, fast vehicles (>50 m/s) +- **Usually**: Player characters (falling at terminal velocity) +- **Sometimes**: Debris, particles (if important) +- **Never**: Static objects, slow-moving props + +**Unity Example**: +```csharp +Rigidbody rb = GetComponent(); + +// For very fast objects (bullets) +rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; + +// For fast objects that hit static geometry (vehicles) +rb.collisionDetectionMode = CollisionDetectionMode.Continuous; + +// Default (fast but can tunnel) +rb.collisionDetectionMode = CollisionDetectionMode.Discrete; +``` + +### 4. Deterministic Physics + +**Why Determinism Matters**: +- Multiplayer: Clients must compute same physics results +- Replays: Must reproduce exact gameplay +- Rollback netcode: Must rewind and re-simulate +- Testing: Must be reproducible for bug fixing + +**Sources of Non-Determinism**: + +1. **Floating-Point Non-Determinism**: +```python +# ❌ Can produce different results on different CPUs +a = 0.1 + 0.2 # Might be 0.30000000000000004 or slightly different + +# ✅ Use fixed-point math for critical calculations +FIXED_POINT_SCALE = 1000 +a = (1 * FIXED_POINT_SCALE + 2 * FIXED_POINT_SCALE) // 10 # Exact +``` + +2. **Iteration Order Non-Determinism**: +```python +# ❌ Dictionary/set iteration order undefined +for obj in physics_objects: # If physics_objects is a dict/set + obj.update() + +# ✅ Sort objects by unique ID for consistent order +sorted_objects = sorted(physics_objects, key=lambda obj: obj.id) +for obj in sorted_objects: + obj.update() +``` + +3. **Multi-Threading Non-Determinism**: +```python +# ❌ Parallel physics updates can happen in any order +parallel_for(objects, lambda obj: obj.update()) + +# ✅ Either: Single-threaded physics updates +for obj in objects: + obj.update() + +# ✅ Or: Deterministic parallel scheduling (island-based) +islands = partition_into_islands(objects) # No dependencies between islands +for island in islands: + parallel_for(island, lambda obj: obj.update()) # Order within island fixed +``` + +4. **Random Number Non-Determinism**: +```python +# ❌ Different seed every run +debris_velocity = random.uniform(-10, 10) + +# ✅ Seeded RNG, advance deterministically +rng = Random(seed=12345) +debris_velocity = rng.uniform(-10, 10) +``` + +**Deterministic Physics Checklist**: +- [ ] Fixed timestep (never variable) +- [ ] Sorted iteration order (by ID or consistent key) +- [ ] Single-threaded physics or deterministic parallel +- [ ] Seeded random number generators +- [ ] Avoid floating-point math in critical paths (or use fixed-point) +- [ ] Consistent compiler flags (same floating-point mode) +- [ ] No OS/hardware dependencies (timer resolution, etc.) + +**Unreal Engine Example**: +```cpp +// Enable deterministic physics in Unreal +// Project Settings > Physics > Simulation +bEnableEnhancedDeterminism = true; + +// Use fixed timestep +FixedDeltaTime = 0.0333f; // 30 Hz for determinism + +// Disable async physics +bTickPhysicsAsync = false; +``` + + +## Decision Frameworks + +### Framework 1: Full Physics vs Kinematic Control + +**Use Full Physics Simulation When**: +- Realistic force-based interactions required (collisions, explosions) +- Unpredictable outcomes are desirable (debris, ragdolls) +- Complex constraint solving needed (vehicles, joints) +- Object interactions with environment are core gameplay + +**Use Kinematic Control When**: +- Precise, predictable movement required (elevators, cutscenes) +- Performance is critical (hundreds of objects) +- Simple animations are sufficient +- No force-based interactions needed + +**Hybrid Approach** (Common): +```python +class Vehicle: + def __init__(self): + self.mode = "kinematic" # Start kinematic + self.rigidbody = None + + def transition_to_physics(self): + """Switch to physics when player takes control""" + self.mode = "physics" + self.rigidbody = create_rigidbody(self) + self.rigidbody.velocity = self.kinematic_velocity + + def transition_to_kinematic(self): + """Switch to kinematic for cutscenes""" + self.kinematic_velocity = self.rigidbody.velocity + destroy_rigidbody(self.rigidbody) + self.mode = "kinematic" + + def update(self, dt): + if self.mode == "physics": + # Full physics simulation + self.apply_forces(dt) + else: + # Direct position control + self.position += self.kinematic_velocity * dt +``` + +**Examples**: +- **Racing game car**: Physics (forces, suspension, tire grip) +- **Racing game camera**: Kinematic (smooth following, no physics) +- **Destructible building**: Physics after destruction trigger +- **Elevator**: Kinematic (predictable timing) +- **Ragdoll**: Kinematic (alive) → Physics (dead) + +### Framework 2: Sub-Stepping Decision + +**Sub-stepping**: Running multiple small physics steps per frame for stability. + +```python +def physics_update(dt): + SUB_STEPS = 4 + sub_dt = dt / SUB_STEPS + + for _ in range(SUB_STEPS): + # Smaller timesteps = more stable + integrate_forces(sub_dt) + solve_constraints(sub_dt) + integrate_velocities(sub_dt) +``` + +**Use Sub-Stepping When**: +- Complex constraints (vehicle suspension, rope physics) +- Stiff springs (high spring constants) +- High-speed collisions requiring accuracy +- Soft body or cloth simulation + +**Skip Sub-Stepping When**: +- Simple rigid bodies with no constraints +- Performance is critical +- Objects are slow-moving +- Using very small base timestep already (>120 Hz) + +**Practical Guidelines**: +- **Most rigid bodies**: 1 step (60 Hz fixed timestep sufficient) +- **Vehicles with suspension**: 2-4 steps +- **Cloth simulation**: 4-10 steps +- **Rope/chain physics**: 8-16 steps + +**Unity Example**: +```csharp +// Project Settings > Time > Fixed Timestep +Time.fixedDeltaTime = 0.0166f; // 60 Hz base + +// Physics Settings > Solver Iteration Count +Physics.defaultSolverIterations = 6; // Velocity +Physics.defaultSolverVelocityIterations = 1; // Position + +// For complex vehicle physics, increase iterations +Rigidbody rb = GetComponent(); +rb.solverIterations = 12; +rb.solverVelocityIterations = 2; +``` + +### Framework 3: Real Physics vs Faked Physics + +**Real Physics** (Forces, constraints, integration): +```python +# Particle with gravity +particle.force += Vector3(0, -9.8 * particle.mass, 0) +particle.velocity += (particle.force / particle.mass) * dt +particle.position += particle.velocity * dt +``` + +**Faked Physics** (Direct manipulation): +```python +# Fake gravity for particles (much faster) +particle.position.y -= 9.8 * dt * dt / 2 +particle.velocity.y -= 9.8 * dt + +# Simple ballistic trajectory (no integration needed) +t = elapsed_time +particle.position = start_position + velocity * t + 0.5 * gravity * t * t +``` + +**Use Real Physics When**: +- Interactions with other physics objects (collisions, joints) +- Forces are complex or change dynamically +- Constraints must be solved (rope, springs) +- Accuracy is critical (gameplay-affecting) + +**Use Faked Physics When**: +- Visual effects only (sparks, dust, blood splatter) +- No interactions with other objects +- Performance is critical (thousands of particles) +- Simple, predictable motion (arcs, bounces) + +**Performance Comparison**: +- **Real physics**: ~100-1000 objects at 60 FPS +- **Faked physics**: ~10,000-100,000 particles at 60 FPS + +**Example - Explosion Debris**: +```python +# GOOD: Nearby debris (10-20 pieces) - real physics +for debris in nearby_debris: + debris.rigidbody.add_force(explosion_force) + debris.rigidbody.add_torque(random_spin) + +# GOOD: Distant particles (1000s) - faked physics +for particle in distant_particles: + particle.velocity = (particle.position - explosion_center).normalized() * speed + particle.lifetime = 2.0 + # Simple ballistic arc, no collision detection +``` + +### Framework 4: When to Use Specific Integration Methods + +| Scenario | Integration Method | Reason | +|----------|-------------------|--------| +| **Rigid body vehicles** | Semi-Implicit Euler | Stable, fast, energy-conserving | +| **Cloth/fabric** | Verlet + PBD | Position-based constraints, stable | +| **Rope/chains** | Verlet + PBD | Easy to constrain, no velocity needed | +| **Space/orbital sim** | RK4 or higher | High accuracy for long-term stability | +| **Soft bodies** | Position-Based Dynamics | Unconditionally stable | +| **Ragdolls** | Semi-Implicit Euler | Fast, good enough for visual quality | +| **Particle effects** | Explicit Euler | Fast, accuracy doesn't matter | +| **Fluids (SPH)** | Leapfrog or Verlet | Symplectic, energy-conserving | + +### Framework 5: Multiplayer Physics Architecture + +Choose your multiplayer architecture based on whether physics is deterministic: + +**Deterministic Physics + Low Latency Required** → **Rollback Netcode**: +- Examples: Fighting games (Street Fighter, Guilty Gear), competitive platformers +- Re-simulates past frames when late inputs arrive +- Requires: Bit-perfect determinism, fast physics (must re-run multiple frames) +- Advantages: Feels instant (no input delay), handles variable latency well +- Disadvantages: Complex to implement, requires deterministic physics + +**Deterministic Physics + Turn-Based or Slow Pace** → **Lock-Step**: +- Examples: RTS games (StarCraft), turn-based strategy +- All clients wait for all inputs before advancing +- Requires: Determinism, same simulation order on all clients +- Advantages: Simple, guaranteed sync, minimal bandwidth +- Disadvantages: Input lag = highest player latency + +**Non-Deterministic Physics** → **Server-Authoritative**: +- Examples: Most MMOs, open-world games, battle royale +- Server runs authoritative physics, sends snapshots to clients +- Clients predict locally, reconcile with server updates +- Advantages: No determinism required, easier to implement, cheat-resistant +- Disadvantages: Bandwidth intensive, visible corrections/rubber-banding + +**Decision Table**: + +| Requirement | Architecture | Implementation Time | +|-------------|--------------|---------------------| +| Physics already deterministic + competitive | Rollback | 2-4 weeks | +| Physics already deterministic + slow-paced | Lock-step | 1-2 weeks | +| Physics NOT deterministic | Server-authoritative | 1-3 weeks | +| Need it working in < 1 week | Server-authoritative | Fastest | +| Converting non-deterministic to deterministic | Refactor first | 1-2 weeks + architecture time | + +**Time-Constrained Multiplayer** (< 1 week): +```python +# Server-authoritative is fastest to implement +class Server: + def update(self): + # Server runs authoritative physics + self.physics_update(FIXED_TIMESTEP) + + # Send snapshots to clients (10-30 Hz) + if time.time() - self.last_snapshot > SNAPSHOT_INTERVAL: + self.broadcast_snapshot(self.get_physics_state()) + +class Client: + def update(self): + # Client predicts locally + self.physics_update(FIXED_TIMESTEP) + + # When server snapshot arrives, reconcile + if snapshot_received: + # Hard snap or interpolate to server state + self.reconcile_with_server(snapshot) +``` + +**Converting to Deterministic Physics** (for rollback/lock-step later): +1. Implement fixed timestep (if not already) +2. Sort all object iteration by ID +3. Seed all RNG deterministically +4. Use fixed-point math for critical calculations (or ensure same FP mode) +5. Make physics single-threaded (or island-based deterministic parallel) +6. Test on different machines for bit-perfect results + +**Critical**: Don't attempt rollback netcode with non-deterministic physics. Either refactor for determinism OR use server-authoritative. + + +## Implementation Patterns + +### Pattern 1: Fixed Timestep Game Loop + +**Complete implementation** with interpolation: + +```python +class GameEngine: + def __init__(self): + self.PHYSICS_TIMESTEP = 1.0 / 60.0 # 16.67ms + self.MAX_FRAME_TIME = 0.25 # Don't spiral if below 4 FPS + self.accumulator = 0.0 + + self.current_state = PhysicsState() + self.previous_state = PhysicsState() + + def run(self): + last_time = time.time() + + while self.running: + current_time = time.time() + frame_time = current_time - last_time + last_time = current_time + + # Cap frame time to prevent spiral of death + if frame_time > self.MAX_FRAME_TIME: + frame_time = self.MAX_FRAME_TIME + + self.accumulator += frame_time + + # Physics updates (fixed timestep) + while self.accumulator >= self.PHYSICS_TIMESTEP: + # Store previous state for interpolation + self.previous_state.copy_from(self.current_state) + + # Physics update + self.physics_update(self.PHYSICS_TIMESTEP) + + self.accumulator -= self.PHYSICS_TIMESTEP + + # Interpolation factor + alpha = self.accumulator / self.PHYSICS_TIMESTEP + + # Render with interpolation + self.render(alpha) + + def physics_update(self, dt): + """Fixed timestep physics""" + # Apply forces + for obj in self.physics_objects: + obj.apply_forces(dt) + + # Integrate + for obj in self.physics_objects: + obj.velocity += (obj.force / obj.mass) * dt + obj.position += obj.velocity * dt + obj.force = Vector3.ZERO + + # Collision detection and response + self.resolve_collisions() + + def render(self, alpha): + """Interpolate between physics states for smooth rendering""" + for obj in self.physics_objects: + # Interpolate position + interpolated_pos = lerp( + obj.previous_position, + obj.current_position, + alpha + ) + + # Interpolate rotation (use slerp for quaternions) + interpolated_rot = slerp( + obj.previous_rotation, + obj.current_rotation, + alpha + ) + + obj.render_at(interpolated_pos, interpolated_rot) +``` + +**Key Points**: +1. Physics always runs at fixed interval (deterministic) +2. Rendering interpolates between states (smooth) +3. Handles both fast and slow frame rates gracefully +4. Prevents spiral of death with max frame time cap + +### Pattern 2: Vehicle Physics with Suspension + +**Realistic vehicle simulation** using ray-cast suspension: + +```python +class VehicleController: + def __init__(self): + self.mass = 1500 # kg + self.wheel_positions = [ + Vector3(-1, 0, 1.5), # Front-left + Vector3(1, 0, 1.5), # Front-right + Vector3(-1, 0, -1.5), # Rear-left + Vector3(1, 0, -1.5), # Rear-right + ] + + # Suspension parameters + self.suspension_length = 0.4 # meters + self.suspension_stiffness = 50000 # N/m + self.suspension_damping = 4500 # N·s/m + + # Tire parameters + self.tire_grip = 2.0 + self.tire_friction_curve = TireFrictionCurve() + + def physics_update(self, dt): + """Vehicle physics with proper suspension""" + total_suspension_force = Vector3.ZERO + + # 1. Ray-cast suspension for each wheel + for i, wheel_pos in enumerate(self.wheel_positions): + world_wheel_pos = self.transform.transform_point(wheel_pos) + ray_direction = -self.transform.up + + hit, hit_distance, hit_normal = raycast( + world_wheel_pos, + ray_direction, + self.suspension_length + ) + + if hit: + # Calculate suspension compression + compression = self.suspension_length - hit_distance + + # Spring force: F = -kx + spring_force = self.suspension_stiffness * compression + + # Damper force: F = -cv (relative to ground) + wheel_velocity = self.get_point_velocity(wheel_pos) + suspension_velocity = Vector3.dot(wheel_velocity, hit_normal) + damper_force = self.suspension_damping * suspension_velocity + + # Total suspension force + total_force = spring_force - damper_force + suspension_force = hit_normal * total_force + + # Apply force at wheel position + self.add_force_at_position(suspension_force, world_wheel_pos) + + # 2. Tire forces (grip and friction) + self.apply_tire_forces(i, world_wheel_pos, hit_normal, dt) + + # 3. Aerodynamic drag + drag_force = -self.velocity * self.velocity.length() * 0.4 + self.add_force(drag_force) + + # 4. Engine force + if self.throttle > 0: + engine_force = self.transform.forward * self.engine_power * self.throttle + self.add_force(engine_force) + + def apply_tire_forces(self, wheel_index, wheel_position, ground_normal, dt): + """Calculate longitudinal and lateral tire forces""" + # Get wheel velocity + wheel_velocity = self.get_point_velocity(wheel_position) + + # Project velocity onto ground plane + ground_velocity = wheel_velocity - ground_normal * Vector3.dot(wheel_velocity, ground_normal) + + # Forward and lateral directions + forward = self.transform.forward + lateral = self.transform.right + + # Slip calculations + forward_speed = Vector3.dot(ground_velocity, forward) + lateral_speed = Vector3.dot(ground_velocity, lateral) + + # Tire friction curve (slip ratio → force) + forward_force = self.tire_friction_curve.evaluate(forward_speed) * self.tire_grip + lateral_force = self.tire_friction_curve.evaluate(lateral_speed) * self.tire_grip + + # Apply tire forces + tire_force = forward * forward_force + lateral * lateral_force + self.add_force_at_position(tire_force, wheel_position) + + def get_point_velocity(self, local_point): + """Get velocity of a point on the vehicle (includes rotation)""" + world_point = self.transform.transform_point(local_point) + offset = world_point - self.center_of_mass + return self.velocity + Vector3.cross(self.angular_velocity, offset) + +class TireFrictionCurve: + """Pacejka tire model (simplified)""" + def evaluate(self, slip): + # Peak grip at ~15% slip + # https://en.wikipedia.org/wiki/Hans_B._Pacejka + B = 10 # Stiffness factor + C = 1.9 # Shape factor + D = 1.0 # Peak value + E = 0.97 # Curvature factor + + return D * math.sin(C * math.atan(B * slip - E * (B * slip - math.atan(B * slip)))) +``` + +**Why This Works**: +- Ray-cast suspension: Handles varying terrain +- Spring-damper model: Realistic suspension behavior +- Tire friction curve: Realistic grip/slip behavior +- Force application at wheel positions: Proper torque for steering + +### Pattern 3: Continuous Collision Detection for Projectiles + +**Swept sphere collision** for bullets and fast projectiles: + +```python +class Projectile: + def __init__(self, position, velocity, radius): + self.position = position + self.velocity = velocity + self.radius = radius + self.active = True + + def update_with_ccd(self, dt, world): + """Update with continuous collision detection""" + if not self.active: + return + + start_position = self.position + end_position = self.position + self.velocity * dt + + # Swept collision detection + collision, hit_time, hit_point, hit_normal = self.swept_sphere_cast( + start_position, + end_position, + self.radius, + world + ) + + if collision: + # Move to collision point + self.position = start_position + self.velocity * (dt * hit_time) + + # Handle collision (bounce, damage, destroy, etc.) + self.on_collision(hit_point, hit_normal) + else: + # No collision, move full distance + self.position = end_position + + def swept_sphere_cast(self, start, end, radius, world): + """Sweep sphere along path, detect first collision""" + direction = (end - start).normalized() + distance = (end - start).length() + + # Ray-cast with radius offset + # Check multiple rays (center + offset in perpendicular directions) + earliest_hit = None + earliest_time = float('inf') + + # Center ray + hit, t, point, normal = world.raycast(start, direction, distance) + if hit and t < earliest_time: + earliest_time = t + earliest_hit = (point, normal) + + # Offset rays (perpendicular to velocity) + perp1, perp2 = get_perpendicular_vectors(direction) + for angle in [0, 90, 180, 270]: + rad = math.radians(angle) + offset = (perp1 * math.cos(rad) + perp2 * math.sin(rad)) * radius + + hit, t, point, normal = world.raycast( + start + offset, + direction, + distance + ) + + if hit and t < earliest_time: + earliest_time = t + earliest_hit = (point, normal) + + if earliest_hit: + point, normal = earliest_hit + return True, earliest_time / distance, point, normal + + return False, None, None, None + + def on_collision(self, hit_point, hit_normal): + """Handle collision response""" + # Example: Destroy projectile and spawn impact effect + self.active = False + spawn_impact_effect(hit_point, hit_normal) + + # Or bounce: + # self.velocity = reflect(self.velocity, hit_normal) * 0.8 +``` + +**Key Features**: +- Swept collision: Never tunnels through thin objects +- Multiple ray-casts: Handles sphere shape accurately +- Early-out: Stops at first collision +- Configurable response: Destroy, bounce, penetrate, etc. + +### Pattern 4: Deterministic Physics for Multiplayer + +**Lock-step multiplayer** with deterministic physics: + +```python +class DeterministicPhysicsEngine: + def __init__(self, seed): + # Fixed-point math for determinism + self.FIXED_POINT_SCALE = 1000 + + # Seeded RNG + self.rng = Random(seed) + + # Sorted objects for consistent iteration + self.physics_objects = [] # Sorted by ID + + # Fixed timestep + self.TIMESTEP = 1.0 / 60.0 + + def add_object(self, obj): + """Add object and maintain sorted order""" + self.physics_objects.append(obj) + self.physics_objects.sort(key=lambda o: o.id) + + def physics_step(self, dt): + """Deterministic physics update""" + assert dt == self.TIMESTEP, "Must use fixed timestep!" + + # Phase 1: Apply forces (sorted order) + for obj in self.physics_objects: # Consistent order + obj.apply_forces(dt) + + # Phase 2: Integrate (sorted order) + for obj in self.physics_objects: + self.integrate_object(obj, dt) + + # Phase 3: Collision detection (sorted pairs) + self.detect_and_resolve_collisions() + + def integrate_object(self, obj, dt): + """Fixed-point integration for determinism""" + # Convert to fixed-point + vel_x = int(obj.velocity.x * self.FIXED_POINT_SCALE) + vel_y = int(obj.velocity.y * self.FIXED_POINT_SCALE) + vel_z = int(obj.velocity.z * self.FIXED_POINT_SCALE) + + acc_x = int(obj.acceleration.x * self.FIXED_POINT_SCALE) + acc_y = int(obj.acceleration.y * self.FIXED_POINT_SCALE) + acc_z = int(obj.acceleration.z * self.FIXED_POINT_SCALE) + + dt_fixed = int(dt * self.FIXED_POINT_SCALE) + + # Semi-implicit Euler (fixed-point) + vel_x += (acc_x * dt_fixed) // self.FIXED_POINT_SCALE + vel_y += (acc_y * dt_fixed) // self.FIXED_POINT_SCALE + vel_z += (acc_z * dt_fixed) // self.FIXED_POINT_SCALE + + pos_x = int(obj.position.x * self.FIXED_POINT_SCALE) + pos_y = int(obj.position.y * self.FIXED_POINT_SCALE) + pos_z = int(obj.position.z * self.FIXED_POINT_SCALE) + + pos_x += (vel_x * dt_fixed) // self.FIXED_POINT_SCALE + pos_y += (vel_y * dt_fixed) // self.FIXED_POINT_SCALE + pos_z += (vel_z * dt_fixed) // self.FIXED_POINT_SCALE + + # Convert back to float + obj.velocity.x = vel_x / self.FIXED_POINT_SCALE + obj.velocity.y = vel_y / self.FIXED_POINT_SCALE + obj.velocity.z = vel_z / self.FIXED_POINT_SCALE + + obj.position.x = pos_x / self.FIXED_POINT_SCALE + obj.position.y = pos_y / self.FIXED_POINT_SCALE + obj.position.z = pos_z / self.FIXED_POINT_SCALE + + def detect_and_resolve_collisions(self): + """Deterministic collision detection""" + # Sort pairs for consistent order + pairs = [] + for i in range(len(self.physics_objects)): + for j in range(i + 1, len(self.physics_objects)): + obj_a = self.physics_objects[i] + obj_b = self.physics_objects[j] + + # Always put lower ID first + if obj_a.id < obj_b.id: + pairs.append((obj_a, obj_b)) + else: + pairs.append((obj_b, obj_a)) + + # Process collisions in sorted order + for obj_a, obj_b in pairs: + if self.check_collision(obj_a, obj_b): + self.resolve_collision(obj_a, obj_b) + + def get_random_value(self): + """Deterministic random numbers""" + return self.rng.random() + +# Usage in multiplayer game: +def multiplayer_game_loop(): + # All clients use same seed + engine = DeterministicPhysicsEngine(seed=game_session_id) + + while running: + # Receive inputs from all players + player_inputs = receive_inputs_from_all_clients() + + # Sort inputs by player ID for determinism + player_inputs.sort(key=lambda inp: inp.player_id) + + # Apply inputs in order + for input in player_inputs: + apply_player_input(input) + + # Run physics (identical on all clients) + engine.physics_step(engine.TIMESTEP) + + # Render (can differ per client) + render() +``` + +**Determinism Guarantees**: +- Fixed-point math: No floating-point non-determinism +- Sorted iteration: Consistent operation order +- Seeded RNG: Reproducible randomness +- Fixed timestep: Same dt every frame +- Single-threaded: No parallel non-determinism + +### Pattern 5: Position-Based Dynamics for Cloth + +**Unconditionally stable cloth simulation**: + +```python +class ClothSimulation: + def __init__(self, width, height, particle_spacing): + self.particles = [] + self.constraints = [] + + # Create particle grid + for y in range(height): + for x in range(width): + pos = Vector3(x * particle_spacing, y * particle_spacing, 0) + particle = ClothParticle(pos, mass=0.1) + self.particles.append(particle) + + # Create distance constraints (structural) + for y in range(height): + for x in range(width): + idx = y * width + x + + # Horizontal constraint + if x < width - 1: + self.constraints.append( + DistanceConstraint( + self.particles[idx], + self.particles[idx + 1], + particle_spacing + ) + ) + + # Vertical constraint + if y < height - 1: + self.constraints.append( + DistanceConstraint( + self.particles[idx], + self.particles[idx + width], + particle_spacing + ) + ) + + # Add shear constraints (diagonals) for stiffness + for y in range(height - 1): + for x in range(width - 1): + idx = y * width + x + + # Diagonal constraints + self.constraints.append( + DistanceConstraint( + self.particles[idx], + self.particles[idx + width + 1], + particle_spacing * math.sqrt(2) + ) + ) + + self.constraints.append( + DistanceConstraint( + self.particles[idx + 1], + self.particles[idx + width], + particle_spacing * math.sqrt(2) + ) + ) + + def simulate(self, dt, iterations=10): + """Position-Based Dynamics simulation""" + # 1. Apply external forces (gravity, wind) + for particle in self.particles: + if not particle.fixed: + particle.velocity += Vector3(0, -9.8, 0) * dt # Gravity + particle.velocity += self.wind_force * dt + + # 2. Predict positions (Verlet integration) + for particle in self.particles: + if not particle.fixed: + particle.predicted_position = particle.position + particle.velocity * dt + + # 3. Solve constraints (multiple iterations for stability) + for _ in range(iterations): + for constraint in self.constraints: + constraint.solve() + + # Collision constraints + for particle in self.particles: + if not particle.fixed: + self.resolve_collisions(particle) + + # 4. Update velocities and positions + for particle in self.particles: + if not particle.fixed: + particle.velocity = (particle.predicted_position - particle.position) / dt + particle.position = particle.predicted_position + + # Velocity damping (air resistance) + particle.velocity *= 0.99 + + def resolve_collisions(self, particle): + """Keep particles above ground plane""" + if particle.predicted_position.y < 0: + particle.predicted_position.y = 0 + # Friction + particle.velocity *= 0.8 + +class ClothParticle: + def __init__(self, position, mass): + self.position = position + self.predicted_position = position + self.velocity = Vector3.ZERO + self.mass = mass + self.fixed = False # Pinned particles don't move + +class DistanceConstraint: + def __init__(self, particle_a, particle_b, rest_length): + self.particle_a = particle_a + self.particle_b = particle_b + self.rest_length = rest_length + + def solve(self): + """Enforce distance constraint""" + if self.particle_a.fixed and self.particle_b.fixed: + return + + delta = self.particle_b.predicted_position - self.particle_a.predicted_position + current_length = delta.length() + + if current_length == 0: + return + + # Correction to restore rest length + correction = delta * (1.0 - self.rest_length / current_length) * 0.5 + + # Apply correction (weighted by mass) + if not self.particle_a.fixed: + self.particle_a.predicted_position += correction + + if not self.particle_b.fixed: + self.particle_b.predicted_position -= correction +``` + +**Why Position-Based Dynamics**: +- **Unconditionally stable**: Cannot explode, even with large timesteps +- **Fast**: Simple position corrections, no matrix solves +- **Intuitive**: Easy to add constraints (distance, bending, collision) +- **Controllable**: Iterations directly control stiffness + +**Used in**: Unity's cloth simulation, many game engines + + +## Common Pitfalls + +### Pitfall 1: Variable Timestep Physics (The Cardinal Sin) + +**The Mistake**: +```python +# ❌ NEVER DO THIS +def update(self, dt): + self.velocity += self.acceleration * dt + self.position += self.velocity * dt +``` + +**Why It Fails**: +- Physics behaves differently at different frame rates +- Integration errors accumulate differently +- Non-deterministic (same inputs ≠ same outputs) +- Multiplayer desyncs guaranteed + +**Real-World Example**: +Game ships, works fine on dev machines (high FPS). Players with low-end hardware (30 FPS) report: +- Cars are "floaty" and hard to control +- Objects fall through floors +- Multiplayer is "laggy" (desyncs) + +**The Fix**: +```python +# ✅ ALWAYS use fixed timestep +FIXED_TIMESTEP = 1.0 / 60.0 +accumulator = 0.0 + +def game_loop(): + global accumulator + dt = get_frame_time() + accumulator += dt + + while accumulator >= FIXED_TIMESTEP: + physics_update(FIXED_TIMESTEP) # Fixed dt + accumulator -= FIXED_TIMESTEP +``` + +**Detection**: +- If `dt` appears in physics calculations, you're at risk +- Test at different frame rates (30 FPS vs 144 FPS) - should behave identically + +### Pitfall 2: Physics Explosions (Energy Accumulation) + +**The Mistake**: +```python +# ❌ Explicit Euler - unstable! +velocity += acceleration * dt +position += velocity * dt # Uses OLD velocity +``` + +**Why It Fails**: +At high speeds or large dt, explicit Euler adds energy to the system. Objects accelerate indefinitely, leading to "physics explosions". + +**Symptoms**: +- Objects suddenly fly off at high speed +- Ragdolls "explode" into the sky +- Vehicles flip and spin uncontrollably +- Happens more at low frame rates + +**Real-World Example**: +Racing game vehicle hits wall at 150 mph. Instead of stopping, it bounces off at 300 mph and flies into orbit. + +**The Fix**: +```python +# ✅ Semi-implicit Euler - stable +velocity += acceleration * dt +position += velocity * dt # Uses NEW velocity + +# Energy is conserved (or slightly dissipated) +``` + +**Why This Works**: +Semi-implicit Euler is symplectic - it conserves energy rather than adding it. Objects slow down naturally instead of speeding up. + +**Additional Safety**: +```python +# Velocity clamping for safety +MAX_VELOCITY = 100.0 +if velocity.length() > MAX_VELOCITY: + velocity = velocity.normalized() * MAX_VELOCITY +``` + +### Pitfall 3: Tunneling (Missing CCD) + +**The Mistake**: +```python +# ❌ Discrete collision detection only +def check_collision(bullet): + if bullet.position inside wall: + bullet.on_collision() +``` + +**Why It Fails**: +Fast objects move multiple body-lengths per frame, skipping over thin obstacles. + +**The Math**: +- Bullet speed: 1000 m/s +- Frame rate: 60 FPS +- Distance per frame: 16.67 meters +- Wall thickness: 0.2 meters +- **Result**: Bullet is on one side of wall in frame N, other side in frame N+1, never "inside" + +**Real-World Example**: +FPS game, players shoot through walls, hitting players on the other side. Especially bad with high ping or low frame rates. + +**The Fix**: +```python +# ✅ Swept collision detection +def update_with_ccd(bullet, dt): + start_pos = bullet.position + end_pos = bullet.position + bullet.velocity * dt + + hit, hit_point, hit_time = swept_raycast(start_pos, end_pos, world) + + if hit: + bullet.position = lerp(start_pos, end_pos, hit_time) + bullet.on_collision(hit_point) + else: + bullet.position = end_pos +``` + +**When CCD is Critical**: +- Projectiles (bullets, arrows) +- Fast vehicles (>50 m/s) +- Falling objects (terminal velocity ~53 m/s) +- Player characters (lunging attacks, dashes) + +**Unity Setting**: +```csharp +rigidbody.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; +``` + +### Pitfall 4: Non-Deterministic Multiplayer Physics + +**The Mistake**: +```python +# ❌ Many sources of non-determinism +def update_physics(): + for obj in physics_objects: # Undefined iteration order + obj.update(Time.deltaTime) # Variable dt + + if random.random() < 0.1: # Non-seeded RNG + obj.apply_impulse() +``` + +**Why It Fails**: +- Dictionary/set iteration order is non-deterministic +- Variable timestep differs on different machines +- Random numbers differ without seeding +- Floating-point math can differ across CPUs + +**Symptoms**: +- Multiplayer clients "desync" (see different physics states) +- Replays don't match original gameplay +- Rollback netcode fails (re-simulation produces different results) +- Heisenbugs (disappear when you try to debug them) + +**Real-World Example**: +Fighting game with rollback netcode. Players see different positions for characters, hits don't register, game is "laggy" despite good ping. Problem: physics is non-deterministic. + +**The Fix**: +```python +# ✅ Deterministic physics +class DeterministicEngine: + def __init__(self, seed): + self.FIXED_TIMESTEP = 1.0 / 60.0 # Fixed dt + self.rng = Random(seed) # Seeded RNG + self.objects = [] # List (ordered), not dict/set + + def update_physics(self): + # Sort for consistent order + sorted_objects = sorted(self.objects, key=lambda o: o.id) + + for obj in sorted_objects: + obj.update(self.FIXED_TIMESTEP) # Fixed dt + + if self.rng.random() < 0.1: # Seeded RNG + obj.apply_impulse() +``` + +**Determinism Checklist**: +- [ ] Fixed timestep (never variable) +- [ ] Sorted iteration order +- [ ] Seeded RNG +- [ ] Single-threaded physics (or deterministic parallel) +- [ ] Consistent floating-point mode +- [ ] Same code/compiler on all clients + +### Pitfall 5: Missing Sub-Stepping for Constraints + +**The Mistake**: +```python +# ❌ Single physics step with stiff constraints +def update(self, dt): + self.solve_constraints() # Only once + self.integrate(dt) +``` + +**Why It Fails**: +Stiff constraints (vehicle suspension, rope physics) need multiple iterations to converge. Single iteration causes jittering and instability. + +**Symptoms**: +- Vehicle suspension jitters and bounces +- Ropes stretch unrealistically +- Joints don't stay connected +- Soft bodies "explode" or collapse + +**Real-World Example**: +Racing game with suspension springs. At 60 FPS with single iteration, cars bounce violently. Suspension never settles. + +**The Fix**: +```python +# ✅ Sub-stepping for constraint stability +def update(self, dt): + SUB_STEPS = 4 + sub_dt = dt / SUB_STEPS + + for _ in range(SUB_STEPS): + self.solve_constraints() + self.integrate(sub_dt) +``` + +**Guidelines**: +- **Simple rigid bodies**: 1 step (no sub-stepping needed) +- **Vehicles with suspension**: 2-4 steps +- **Rope/chain physics**: 8-16 steps +- **Cloth simulation**: 4-10 steps + +**Unity Example**: +```csharp +// Increase solver iterations for complex constraints +Rigidbody rb = GetComponent(); +rb.solverIterations = 12; // Default is 6 +rb.solverVelocityIterations = 2; // Default is 1 +``` + +### Pitfall 6: Ignoring Center of Mass + +**The Mistake**: +```python +# ❌ Apply force at object origin +def apply_force(self, force): + self.acceleration = force / self.mass + # No torque calculation! +``` + +**Why It Fails**: +Forces not applied at center of mass create torque. Ignoring this makes objects rotate incorrectly or not at all. + +**Real-World Example**: +Car accelerates, but nose doesn't dip down. Car turns, but body doesn't lean. Feels unrealistic. + +**The Fix**: +```python +# ✅ Calculate torque from force application point +def add_force_at_position(self, force, world_position): + # Linear force + self.acceleration += force / self.mass + + # Angular force (torque) + offset = world_position - self.center_of_mass + torque = Vector3.cross(offset, force) + self.angular_acceleration += torque / self.moment_of_inertia +``` + +**Correct Center of Mass**: +```python +# Calculate center of mass for compound object +def calculate_center_of_mass(self): + total_mass = 0 + weighted_position = Vector3.ZERO + + for part in self.parts: + total_mass += part.mass + weighted_position += part.position * part.mass + + self.center_of_mass = weighted_position / total_mass +``` + +**Unity Tip**: +```csharp +// Set center of mass for vehicles +Rigidbody rb = GetComponent(); +rb.centerOfMass = new Vector3(0, -0.5f, 0); // Lower = more stable +``` + +### Pitfall 7: Wrong Collision Response + +**The Mistake**: +```python +# ❌ Incorrect collision impulse +def resolve_collision(a, b): + a.velocity = -a.velocity # Just reverse direction + b.velocity = -b.velocity +``` + +**Why It Fails**: +- Doesn't conserve momentum +- Doesn't account for relative masses +- Ignores coefficient of restitution (bounciness) +- No friction + +**Real-World Example**: +Small box hits large truck. Box bounces off at same speed, truck also bounces. Physics looks wrong (momentum not conserved). + +**The Fix**: +```python +# ✅ Physically correct impulse-based collision +def resolve_collision(a, b, collision_normal, restitution=0.5): + # Relative velocity + relative_velocity = a.velocity - b.velocity + velocity_along_normal = Vector3.dot(relative_velocity, collision_normal) + + # Don't resolve if separating + if velocity_along_normal > 0: + return + + # Calculate impulse magnitude + # Formula: j = -(1 + e) * v_rel · n / (1/m_a + 1/m_b) + impulse_magnitude = -(1 + restitution) * velocity_along_normal + impulse_magnitude /= (1 / a.mass + 1 / b.mass) + + # Apply impulse + impulse = collision_normal * impulse_magnitude + a.velocity += impulse / a.mass + b.velocity -= impulse / b.mass +``` + +**With Friction**: +```python +def resolve_collision_with_friction(a, b, collision_normal, restitution=0.5, friction=0.3): + # Normal impulse (from above) + # ... normal impulse calculation ... + + # Tangential (friction) impulse + relative_velocity = a.velocity - b.velocity + tangent = relative_velocity - collision_normal * Vector3.dot(relative_velocity, collision_normal) + + if tangent.length() > 0: + tangent = tangent.normalized() + + # Friction impulse (capped by friction coefficient) + friction_magnitude = -Vector3.dot(relative_velocity, tangent) + friction_magnitude /= (1 / a.mass + 1 / b.mass) + friction_magnitude = clamp(friction_magnitude, -friction * impulse_magnitude, friction * impulse_magnitude) + + friction_impulse = tangent * friction_magnitude + a.velocity += friction_impulse / a.mass + b.velocity -= friction_impulse / b.mass +``` + + +## Real-World Examples + +### Example 1: Unity Vehicle Physics + +**Unity's WheelCollider** (simplified conceptual implementation): + +```csharp +public class UnityVehiclePhysics : MonoBehaviour +{ + public WheelCollider frontLeft, frontRight, rearLeft, rearRight; + public float motorTorque = 1500f; + public float brakeTorque = 3000f; + public float maxSteerAngle = 30f; + + private Rigidbody rb; + + void Start() + { + rb = GetComponent(); + + // Lower center of mass for stability + rb.centerOfMass = new Vector3(0, -0.5f, 0); + + // Continuous collision for high-speed stability + rb.collisionDetectionMode = CollisionDetectionMode.Continuous; + } + + void FixedUpdate() // ← Fixed timestep (50 Hz default) + { + // Input + float motor = Input.GetAxis("Vertical") * motorTorque; + float steering = Input.GetAxis("Horizontal") * maxSteerAngle; + float brake = Input.GetKey(KeyCode.Space) ? brakeTorque : 0; + + // Apply to wheels + frontLeft.steerAngle = steering; + frontRight.steerAngle = steering; + + rearLeft.motorTorque = motor; + rearRight.motorTorque = motor; + + frontLeft.brakeTorque = brake; + frontRight.brakeTorque = brake; + rearLeft.brakeTorque = brake; + rearRight.brakeTorque = brake; + } +} + +// WheelCollider does internally: +// - Ray-cast suspension (spring-damper model) +// - Tire friction curve (Pacejka model) +// - Sub-stepping for stability +// - Force application at contact point +``` + +**Key Patterns**: +1. Fixed timestep via `FixedUpdate()` - deterministic physics +2. Continuous collision detection - prevents tunneling at high speeds +3. Lowered center of mass - improves vehicle stability +4. WheelCollider handles complex tire physics automatically + +### Example 2: Unreal Engine Chaos Physics + +**Vehicle physics in Unreal** (Blueprint/C++): + +```cpp +// Unreal's vehicle physics (simplified concepts) +class AVehiclePawn : public APawn +{ +public: + void SetupVehicle() + { + // Create physics vehicle + VehicleMovement = CreateDefaultSubobject(TEXT("VehicleMovement")); + + // Configure wheels + VehicleMovement->WheelSetups.SetNum(4); + + // Front wheels (steering) + VehicleMovement->WheelSetups[0].WheelClass = UFrontWheel::StaticClass(); + VehicleMovement->WheelSetups[1].WheelClass = UFrontWheel::StaticClass(); + + // Rear wheels (drive) + VehicleMovement->WheelSetups[2].WheelClass = URearWheel::StaticClass(); + VehicleMovement->WheelSetups[3].WheelClass = URearWheel::StaticClass(); + + // Engine setup + VehicleMovement->MaxEngineRPM = 6000.0f; + VehicleMovement->EngineSetup.TorqueCurve.GetRichCurve()->AddKey(0.0f, 400.0f); + VehicleMovement->EngineSetup.TorqueCurve.GetRichCurve()->AddKey(3000.0f, 500.0f); + VehicleMovement->EngineSetup.TorqueCurve.GetRichCurve()->AddKey(6000.0f, 400.0f); + + // Transmission + VehicleMovement->TransmissionSetup.bUseGearAutoBox = true; + VehicleMovement->TransmissionSetup.GearSwitchTime = 0.5f; + } + + void Tick(float DeltaTime) override + { + // Physics runs in substepped fixed timestep + // Unreal handles this automatically via Chaos physics + } +}; + +// Enable deterministic physics in Unreal +// Edit > Project Settings > Physics +// - Substepping: Enabled +// - Max Substep Delta Time: 0.0166 (60 Hz) +// - Max Substeps: 6 +``` + +**Key Patterns**: +1. Sub-stepping for constraint stability (suspension, gears) +2. Torque curves for realistic engine behavior +3. Automatic gear shifting (state machine) +4. Wheel classes with tire friction models + +### Example 3: Rocket League Physics + +**Vehicle physics with aerodynamics** (conceptual): + +```python +class RocketLeagueVehicle: + def __init__(self): + self.rigidbody = Rigidbody(mass=180) # kg + self.boost_force = 30000 # Newtons + self.jump_impulse = 5000 + + # Aerial control + self.air_control_torque = 400 + self.air_damping = 0.3 + + # Ground physics + self.wheel_friction = 3.0 + self.drift_friction = 1.5 + + def fixed_update(self, dt): # Fixed 120 Hz + """Physics update at 120 Hz for responsiveness""" + + if self.is_grounded(): + self.apply_ground_physics(dt) + else: + self.apply_aerial_physics(dt) + + # Boost + if self.boost_active and self.boost_fuel > 0: + boost_direction = self.transform.forward + self.rigidbody.add_force(boost_direction * self.boost_force) + self.boost_fuel -= 30 * dt # 30 boost per second + + def apply_ground_physics(self, dt): + """Wheel-based ground control""" + # Steering input + steer_angle = self.input.steering * 30 # degrees + + # Apply wheel forces + for wheel in self.wheels: + # Forward force from throttle + forward_force = self.transform.forward * self.input.throttle * 1500 + + # Lateral friction (with drift reduction) + friction_multiplier = self.drift_friction if self.input.drift else self.wheel_friction + lateral_force = self.calculate_tire_force(wheel, friction_multiplier) + + self.rigidbody.add_force_at_position( + forward_force + lateral_force, + wheel.world_position + ) + + def apply_aerial_physics(self, dt): + """Aerial control via orientation torque""" + # Air roll, pitch, yaw + torque = Vector3( + self.input.pitch * self.air_control_torque, + self.input.yaw * self.air_control_torque, + self.input.roll * self.air_control_torque + ) + + self.rigidbody.add_torque(torque) + + # Air damping (reduces rotation speed in air) + self.rigidbody.angular_velocity *= (1.0 - self.air_damping * dt) + + # Gravity + self.rigidbody.add_force(Vector3(0, -9.8 * self.rigidbody.mass, 0)) + + def jump(self): + """Dodge/jump mechanics""" + if self.can_jump: + self.rigidbody.add_impulse(Vector3.UP * self.jump_impulse) + self.can_jump = False + + # Dodge: Add impulse + angular momentum + if self.input.direction.length() > 0: + dodge_direction = self.input.direction.normalized() + self.rigidbody.add_impulse(dodge_direction * self.jump_impulse * 0.8) + + # Flip car + axis = Vector3.cross(Vector3.UP, dodge_direction) + self.rigidbody.add_angular_impulse(axis * 10) + +# Fixed timestep game loop +PHYSICS_RATE = 120 # Hz (higher for competitive gameplay) +FIXED_DT = 1.0 / PHYSICS_RATE + +def game_loop(): + accumulator = 0.0 + + while running: + frame_time = get_delta_time() + accumulator += frame_time + + while accumulator >= FIXED_DT: + vehicle.fixed_update(FIXED_DT) # 120 Hz physics + accumulator -= FIXED_DT + + render(accumulator / FIXED_DT) # Interpolate +``` + +**Key Patterns**: +1. High-frequency fixed timestep (120 Hz) for competitive responsiveness +2. State-based physics (grounded vs aerial) +3. Drift mechanics via friction modulation +4. Aerial control via direct torque application +5. Boost as continuous force (not impulse) + +### Example 4: Half-Life 2 Gravity Gun + +**Constraint-based object manipulation**: + +```cpp +// Valve's physics manipulation (conceptual) +class CGravityGun +{ +public: + void HoldObject(CPhysicsObject* pObject) + { + // Create spring constraint to hold object + m_pHeldObject = pObject; + + // Target position: In front of player + Vector targetPos = GetPlayer()->GetEyePosition() + GetPlayer()->GetForward() * m_flHoldDistance; + + // Create spring-damper constraint + m_pConstraint = CreateSpringConstraint( + pObject, + targetPos, + stiffness: 1000.0f, // Strong spring + damping: 50.0f // Moderate damping + ); + + // Reduce object's angular velocity for stability + pObject->SetAngularDamping(0.8f); + } + + void UpdateHeldObject(float dt) + { + if (!m_pHeldObject) return; + + // Update target position + Vector targetPos = GetPlayer()->GetEyePosition() + GetPlayer()->GetForward() * m_flHoldDistance; + + // Calculate spring force + Vector offset = targetPos - m_pHeldObject->GetPosition(); + Vector force = offset * m_flStiffness; + + // Calculate damping force + Vector velocity = m_pHeldObject->GetVelocity(); + Vector dampingForce = -velocity * m_flDamping; + + // Apply forces + m_pHeldObject->AddForce(force + dampingForce); + + // Rotate object to face player (optional) + Quaternion targetRotation = LookRotation(GetPlayer()->GetForward()); + Quaternion currentRotation = m_pHeldObject->GetRotation(); + Quaternion deltaRotation = ShortestRotation(currentRotation, targetRotation); + + Vector torque = deltaRotation.ToTorque() * 100.0f; + m_pHeldObject->AddTorque(torque); + } + + void LaunchObject() + { + if (!m_pHeldObject) return; + + // Punt object forward + Vector launchVelocity = GetPlayer()->GetForward() * m_flLaunchSpeed; + m_pHeldObject->SetVelocity(launchVelocity); + + // Restore normal damping + m_pHeldObject->SetAngularDamping(0.05f); + + // Destroy constraint + DestroyConstraint(m_pConstraint); + m_pHeldObject = nullptr; + } +}; +``` + +**Key Patterns**: +1. Spring-damper constraint for smooth following +2. Angular damping to reduce oscillation +3. Constraint-based (not direct position setting) +4. Smooth transition from constraint to free physics + +### Example 5: Angry Birds Destruction + +**Large-scale destructible physics**: + +```csharp +// Unity-based destruction (Angry Birds style) +public class DestructibleStructure : MonoBehaviour +{ + public float health = 100f; + public GameObject[] debrisPrefabs; + + private Rigidbody rb; + private bool isDestroyed = false; + + void Start() + { + rb = GetComponent(); + + // Static until hit (optimization) + rb.isKinematic = true; + } + + void OnCollisionEnter(Collision collision) + { + // Calculate damage from impact + float impactForce = collision.impulse.magnitude; + float damage = impactForce / rb.mass; + + health -= damage; + + if (health <= 0 && !isDestroyed) + { + Destroy(); + } + else if (rb.isKinematic) + { + // Transition to physics when hit + rb.isKinematic = false; + } + } + + void Destroy() + { + isDestroyed = true; + + // Spawn debris pieces + for (int i = 0; i < debrisPrefabs.Length; i++) + { + Vector3 spawnPos = transform.position + Random.insideUnitSphere * 0.5f; + GameObject debris = Instantiate(debrisPrefabs[i], spawnPos, Random.rotation); + + Rigidbody debrisRb = debris.GetComponent(); + + // Inherit velocity + debrisRb.velocity = rb.velocity; + + // Add explosion impulse + Vector3 explosionDir = (spawnPos - transform.position).normalized; + debrisRb.AddForce(explosionDir * 500f, ForceMode.Impulse); + + // Random spin + debrisRb.AddTorque(Random.insideUnitSphere * 10f, ForceMode.Impulse); + + // Auto-despawn after 5 seconds + Destroy(debris, 5f); + } + + // Destroy original object + Destroy(gameObject); + } +} + +// Bird projectile +public class Bird : MonoBehaviour +{ + private Rigidbody rb; + + void Start() + { + rb = GetComponent(); + + // Continuous collision (fast-moving) + rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; + + // High mass for destruction + rb.mass = 10f; + } + + void FixedUpdate() + { + // Apply drag for realistic arc + rb.velocity *= 0.99f; + + // Rotate to face direction of travel + if (rb.velocity.magnitude > 0.1f) + { + transform.rotation = Quaternion.LookRotation(rb.velocity); + } + } +} +``` + +**Key Patterns**: +1. Kinematic → Dynamic transition (optimization) +2. Impulse-based damage calculation +3. Debris spawning with inherited velocity +4. CCD for fast-moving projectiles +5. Auto-despawn for performance + + +## Cross-References + +### Use This Skill WITH: +- **performance-optimization-patterns**: Physics is expensive; profile and optimize +- **netcode-patterns**: Deterministic physics for multiplayer synchronization +- **state-machines**: Managing physics states (grounded, aerial, ragdoll) +- **pooling-patterns**: Reusing physics objects (debris, particles) + +### Use This Skill AFTER: +- **game-architecture-fundamentals**: Understand fixed update loops +- **3d-math-essentials**: Vector math, quaternions, coordinate spaces +- **collision-detection-patterns**: Broad-phase, narrow-phase algorithms + +### Related Skills: +- **character-controller-patterns**: Kinematic character control vs physics-based +- **animation-blending**: Transitioning between animation and ragdoll +- **vfx-patterns**: Faking physics for visual effects + + +## Performance Optimization + +When physics becomes a bottleneck (< 60 FPS with target object count), apply these optimizations: + +### 1. Spatial Partitioning (Broad-Phase Collision) + +**Problem**: Checking every object pair is O(n²) - with 100 objects, that's 4,950 checks per frame. + +**Solution - Uniform Grid**: +```python +class SpatialGrid: + def __init__(self, cell_size): + self.cell_size = cell_size + self.grid = {} # Dict[Tuple[int, int], List[Object]] + + def insert(self, obj): + """Insert object into grid cells it overlaps""" + cells = self.get_cells_for_bounds(obj.bounds) + for cell in cells: + if cell not in self.grid: + self.grid[cell] = [] + self.grid[cell].append(obj) + + def get_potential_collisions(self, obj): + """Get only nearby objects (same or adjacent cells)""" + cells = self.get_cells_for_bounds(obj.bounds) + nearby = set() + + for cell in cells: + if cell in self.grid: + nearby.update(self.grid[cell]) + + return nearby + + def clear(self): + """Clear grid each frame before re-inserting""" + self.grid.clear() + +# Usage: +def physics_update(dt): + grid = SpatialGrid(cell_size=10.0) + + # Insert all objects + for obj in physics_objects: + grid.insert(obj) + + # Collision detection (only check nearby) + for obj in physics_objects: + nearby = grid.get_potential_collisions(obj) + for other in nearby: + if obj.id < other.id: # Avoid duplicate checks + check_collision(obj, other) +``` + +**Performance**: O(n) instead of O(n²). With 100 objects, ~400 checks vs 4,950. + +### 2. Physics Islands and Sleeping Objects + +**Concept**: Objects at rest don't need simulation until disturbed. + +```python +class SleepingObject: + def __init__(self): + self.is_sleeping = False + self.sleep_timer = 0.0 + self.sleep_threshold_time = 0.5 # seconds + self.sleep_velocity_threshold = 0.01 # m/s + + def update(self, dt): + if self.is_sleeping: + return # Skip physics simulation + + # Check if object should sleep + if self.velocity.length() < self.sleep_velocity_threshold: + self.sleep_timer += dt + if self.sleep_timer > self.sleep_threshold_time: + self.is_sleeping = True + else: + self.sleep_timer = 0.0 + + # Normal physics update + self.integrate(dt) + + def wake_up(self): + """Called when object is hit or disturbed""" + self.is_sleeping = False + self.sleep_timer = 0.0 + +# Propagate wake-up through connected objects +def on_collision(obj_a, obj_b): + if obj_a.is_sleeping: + obj_a.wake_up() + if obj_b.is_sleeping: + obj_b.wake_up() +``` + +**Performance**: Large static environments (buildings, props) don't consume CPU when untouched. + +### 3. Level of Detail (LOD) for Physics + +**Concept**: Simplify physics for distant objects. + +```python +class PhysicsLOD: + def __init__(self, camera): + self.camera = camera + + # Distance thresholds + self.FULL_PHYSICS_DISTANCE = 50.0 # < 50m: Full physics + self.SIMPLIFIED_PHYSICS_DISTANCE = 200.0 # 50-200m: Reduced fidelity + # > 200m: Kinematic or disabled + + def get_lod_level(self, obj): + distance = (obj.position - self.camera.position).length() + + if distance < self.FULL_PHYSICS_DISTANCE: + return "FULL" + elif distance < self.SIMPLIFIED_PHYSICS_DISTANCE: + return "SIMPLIFIED" + else: + return "KINEMATIC" + + def update_physics(self, obj, dt): + lod = self.get_lod_level(obj) + + if lod == "FULL": + # Full physics: All features, high solver iterations + obj.solver_iterations = 12 + obj.enable_ccd = True + obj.update_full_physics(dt) + + elif lod == "SIMPLIFIED": + # Simplified: Reduced iterations, no CCD + obj.solver_iterations = 4 + obj.enable_ccd = False + obj.update_full_physics(dt) + + else: # KINEMATIC + # No physics simulation, just update position + obj.update_kinematic(dt) +``` + +**Performance**: Can handle 3-10x more objects by reducing fidelity for distant objects. + +### 4. Solver Iteration Tuning + +**Trade-off**: More iterations = more stable constraints, but slower. + +```csharp +// Unity example - tune per object +Rigidbody rb = GetComponent(); + +// Simple objects (debris, props) +rb.solverIterations = 4; // Velocity constraints +rb.solverVelocityIterations = 1; // Position constraints + +// Complex objects (vehicles with suspension) +rb.solverIterations = 12; +rb.solverVelocityIterations = 2; + +// Critical objects (player character) +rb.solverIterations = 20; +rb.solverVelocityIterations = 4; +``` + +**Guideline**: Start low (4/1), increase only if jittering or instability observed. + +### 5. Collision Layer Matrix + +**Concept**: Many object pairs never need collision checks. + +```csharp +// Unity: Edit > Project Settings > Physics > Layer Collision Matrix +// Example layer setup: +// - Layer 8: PlayerBullets (collide with Enemies, Environment) +// - Layer 9: EnemyBullets (collide with Player, Environment) +// - Layer 10: Environment (collide with everything) +// - Layer 11: Debris (collide only with Environment) + +// Bullets don't collide with each other (huge savings) +Physics.IgnoreLayerCollision(8, 8); // PlayerBullets vs PlayerBullets +Physics.IgnoreLayerCollision(9, 9); // EnemyBullets vs EnemyBullets +Physics.IgnoreLayerCollision(8, 9); // PlayerBullets vs EnemyBullets + +// Debris doesn't collide with bullets (performance) +Physics.IgnoreLayerCollision(11, 8); +Physics.IgnoreLayerCollision(11, 9); +``` + +**Performance**: Can reduce collision checks by 50-90% depending on game. + +### Performance Optimization Checklist +- [ ] Spatial partitioning implemented (grid, octree, or sort-and-sweep) +- [ ] Sleeping/waking system for static objects +- [ ] Physics LOD based on distance to camera +- [ ] Solver iterations tuned per object type (not all need 12) +- [ ] Collision layers configured (ignore unnecessary pairs) +- [ ] Profiled to identify actual bottleneck (don't guess) + + +## Debugging Guide + +### Debugging Physics Explosions + +**Symptoms**: Objects suddenly fly off at extreme speeds, spin wildly, or "explode" apart. + +**Diagnosis Checklist**: +1. **Check integration method**: + ```python + # ❌ BAD: Explicit Euler + velocity += acceleration * dt + position += velocity * dt # Uses OLD velocity + + # ✅ GOOD: Semi-implicit Euler + velocity += acceleration * dt + position += velocity * dt # Uses NEW velocity + ``` + +2. **Add velocity clamping**: + ```python + MAX_VELOCITY = 100.0 # m/s (adjust for your game) + if velocity.length() > MAX_VELOCITY: + velocity = velocity.normalized() * MAX_VELOCITY + ``` + +3. **Check for NaN/Infinity**: + ```python + def safe_normalize(vector): + length = vector.length() + if length < 0.0001 or math.isnan(length) or math.isinf(length): + return Vector3(0, 1, 0) # Default direction + return vector / length + ``` + +4. **Verify timestep size**: + ```python + assert dt <= 0.033, f"Timestep too large: {dt}s (should be ≤ 0.033)" + ``` + +### Debugging Tunneling + +**Symptoms**: Fast objects pass through walls, bullets hit players behind walls. + +**Diagnosis Checklist**: +1. **Calculate required CCD threshold**: + ```python + # Rule: Enable CCD if object moves > half its size per frame + velocity_per_frame = velocity * dt + object_size = collider.radius * 2 + + if velocity_per_frame > object_size * 0.5: + print(f"⚠️ CCD required! Moving {velocity_per_frame}m, size {object_size}m") + enable_ccd() + ``` + +2. **Verify CCD is enabled**: + ```csharp + // Unity + Debug.Log($"CCD Mode: {rigidbody.collisionDetectionMode}"); + // Should be Continuous or ContinuousDynamic + ``` + +3. **Check collider thickness**: + ```python + # Walls should be at least 2x the distance fast objects travel per frame + min_wall_thickness = max_object_speed * FIXED_TIMESTEP * 2 + print(f"Minimum wall thickness: {min_wall_thickness}m") + ``` + +### Debugging Multiplayer Desyncs + +**Symptoms**: Clients see different physics states, objects in different positions. + +**Diagnosis Process**: +1. **Enable determinism logging**: + ```python + def log_physics_state(frame): + # Log complete physics state each frame + state_hash = hash_physics_state(physics_objects) + print(f"Frame {frame}: Hash {state_hash}") + + # Log first object details for debugging + obj = physics_objects[0] + print(f" Obj[0]: pos={obj.position}, vel={obj.velocity}") + ``` + +2. **Compare logs from both clients**: + ```bash + # If hashes differ, find first divergence frame + diff client1.log client2.log + ``` + +3. **Check iteration order**: + ```python + # ❌ BAD: Undefined order + for obj in physics_objects: # If dict/set + obj.update() + + # ✅ GOOD: Sorted order + for obj in sorted(physics_objects, key=lambda o: o.id): + obj.update() + ``` + +4. **Verify RNG synchronization**: + ```python + # Both clients must use same seed + rng = Random(seed=game_session_id) + + # Log RNG calls + value = rng.random() + print(f"RNG: {value}") # Should match on both clients + ``` + +5. **Test on different machines**: + ```python + # Different CPUs can produce different floating-point results + # Use fixed-point math if needed: + def to_fixed(value, scale=1000): + return int(value * scale) + + def from_fixed(fixed_value, scale=1000): + return fixed_value / scale + ``` + +### Debugging Jittery Constraints + +**Symptoms**: Vehicle suspension bounces, ropes vibrate, joints don't settle. + +**Diagnosis**: +1. **Increase sub-steps**: + ```python + # Current: 1 step + physics_update(dt) + + # Fix: 4 sub-steps + for _ in range(4): + physics_update(dt / 4) + ``` + +2. **Increase solver iterations** (Unity): + ```csharp + rigidbody.solverIterations = 12; // Try 8, 12, 16 + ``` + +3. **Check spring stiffness** (might be too high): + ```python + # Too stiff: stiffness = 100000 + # Better: stiffness = 50000 (or lower) + suspension_stiffness = 50000 + ``` + +### Common Debugging Commands + +```python +# Enable physics visualization +debug_draw_colliders = True +debug_draw_forces = True +debug_draw_velocities = True + +# Frame-by-frame physics +pause_physics = True +step_one_frame = False + +if step_one_frame or not pause_physics: + physics_update(dt) + step_one_frame = False + +# Slow motion +time_scale = 0.1 # 10x slower +physics_update(dt * time_scale) +``` + + +## Testing Checklist + +### Fixed Timestep Verification +- [ ] Physics runs at consistent rate regardless of frame rate +- [ ] Test at 30 FPS, 60 FPS, 144 FPS - identical behavior +- [ ] Rendering interpolates smoothly between physics states +- [ ] No spiral of death at low frame rates (frame time clamped) + +### Stability Testing +- [ ] No physics explosions at high speeds +- [ ] Objects settle into rest (don't vibrate forever) +- [ ] Stacked objects are stable (no jittering) +- [ ] Constraints converge (suspension, ropes, chains) + +### Collision Detection +- [ ] Fast objects don't tunnel through thin walls +- [ ] CCD enabled for projectiles and fast vehicles +- [ ] Collision response conserves momentum +- [ ] Friction behaves realistically + +### Determinism Testing (Multiplayer) +- [ ] Same inputs produce same outputs (bit-identical) +- [ ] Fixed timestep (never variable) +- [ ] Sorted iteration order (by ID or consistent key) +- [ ] Seeded random number generator +- [ ] Single-threaded physics or deterministic parallel +- [ ] Tested on different machines (same results) + +### Performance Testing +- [ ] Meets target frame rate with max object count +- [ ] Spatial partitioning for collision broad-phase +- [ ] Physics islands for sleeping objects +- [ ] LOD for distant physics objects + +### Edge Cases +- [ ] Handles zero/infinite/NaN values gracefully +- [ ] Extreme velocities clamped or handled +- [ ] Extreme forces don't cause explosions +- [ ] Division by zero checks (mass, distance, etc.) + +### Integration Testing +- [ ] Physics integrates with game state (damage, score, etc.) +- [ ] Transitions between physics states work (kinematic ↔ dynamic) +- [ ] Save/load preserves physics state +- [ ] Replay system reproduces physics accurately + + +## Summary + +Physics simulation for games requires balancing realism, performance, and stability. The core principles are: + +1. **Always use fixed timestep** - Determinism and stability +2. **Choose integration method carefully** - Semi-implicit Euler for most cases +3. **Use CCD for fast objects** - Prevent tunneling +4. **Design for determinism** - Critical for multiplayer +5. **Sub-step complex constraints** - Vehicles, cloth, ropes +6. **Fake physics when possible** - Visual effects don't need real physics +7. **Test under pressure** - High speeds, low frame rates, edge cases + +Master these patterns and avoid the common pitfalls, and your physics systems will be stable, performant, and feel great to play. diff --git a/skills/using-simulation-tactics/simulation-vs-faking.md b/skills/using-simulation-tactics/simulation-vs-faking.md new file mode 100644 index 0000000..a380c7e --- /dev/null +++ b/skills/using-simulation-tactics/simulation-vs-faking.md @@ -0,0 +1,2815 @@ + +# Simulation vs Faking: The Foundational Trade-off + +## Purpose + +**This is the MOST CRITICAL skill in the simulation-tactics skillpack.** It teaches the fundamental decision framework that prevents two catastrophic failure modes: + +1. **Over-simulation**: Wasting performance simulating things players never notice +2. **Under-simulation**: Breaking immersion by failing to simulate what players DO notice + +Every other simulation skill builds on this foundation. Master this first. + + +## When to Use This Skill + +Use this skill when: +- Designing ANY game system with simulation elements +- Facing performance budgets with complex simulations +- Deciding what to simulate vs what to fake +- Players can observe systems at varying levels of scrutiny +- Building NPCs, crowds, ecosystems, economies, physics, or AI +- Choosing between realistic simulation and performance +- System has background elements and foreground elements + +**ALWAYS use this skill BEFORE implementing simulation systems.** Retrofitting after over-engineering is painful. + + +## Core Philosophy: The "Good Enough" Threshold + +### The Fundamental Truth + +> **Players don't experience your simulation—they experience their PERCEPTION of your simulation.** + +The goal is not perfect simulation. The goal is creating the ILLUSION of a living, breathing world within your performance budget. + +### The Good Enough Threshold + +For every system, there exists a "good enough" threshold where: +- **Below**: Players notice something is off (immersion breaks) +- **Above**: Players don't notice improvements (wasted performance) + +Your job is to find this threshold and stay JUST above it. + +### Example: NPC Hunger + +Consider an NPC hunger system: + +**Over-Simulated** (wasted performance): +``` +Hunger = 100.0 +Every frame: Hunger -= 0.0001 * Time.deltaTime +Tracks: Last meal, calorie intake, metabolism rate, digestion state +Result: Frame-perfect accuracy nobody notices +Cost: 0.1ms per NPC × 100 NPCs = 10ms +``` + +**Good Enough** (optimized): +``` +Hunger = 100.0 +Every 60 seconds: Hunger -= 5.0 +Tracks: Just hunger value +Result: Player sees NPC eat when hungry +Cost: 0.001ms per NPC × 100 NPCs = 0.1ms (100× faster) +``` + +**Under-Simulated** (breaks immersion): +``` +Hunger = always 50 +NPCs never eat +Result: Player notices NPCs don't eat for days +Cost: 0ms but ruins experience +``` + +The middle option is "good enough"—NPCs eat, players believe the simulation, performance is fine. + + +## CORE CONCEPT #1: Player Scrutiny Levels + +The SINGLE MOST IMPORTANT factor in simulation-vs-faking decisions is: **How closely will the player observe this?** + +### Scrutiny Hierarchy + +``` +█████████████████████████ EXTREME SCRUTINY █████████████████████████ +│ Center screen, zoomed in, player controlling +│ Examples: Player character, boss enemy, inspected NPC +│ Strategy: FULL SIMULATION, no corners cut +│ Budget: High (0.5-2ms per entity) +│ +████████████████████████ HIGH SCRUTINY ████████████████████████ +│ On screen, player watching, can interact +│ Examples: Enemy in combat, nearby NPC, active vehicle +│ Strategy: DETAILED SIMULATION with minor optimizations +│ Budget: Medium (0.1-0.5ms per entity) +│ +███████████████████ MEDIUM SCRUTINY ███████████████████ +│ On screen, visible, background +│ Examples: Crowd member, distant traffic, ambient wildlife +│ Strategy: HYBRID (key features real, details faked) +│ Budget: Low (0.01-0.05ms per entity) +│ +██████████████ LOW SCRUTINY ██████████████ +│ Barely visible, distant, or peripheral +│ Examples: Distant NPCs, far traffic, background crowd +│ Strategy: MOSTLY FAKE with occasional reality +│ Budget: Minimal (0.001-0.01ms per entity) +│ +█████ MINIMAL SCRUTINY █████ +│ Off-screen, occluded, or player never observes +│ Examples: NPCs in buildings, crowd outside view, distant city +│ Strategy: FULLY FAKE or statistical +│ Budget: Negligible (0.0001ms per entity or bulk) +│ +``` + +### Scrutiny-Based Decision Matrix + +| Scrutiny | Simulation Level | Examples | Techniques | +|----------|-----------------|----------|------------| +| **Extreme** | 100% real | Player character, inspected NPC, boss | Full physics, full AI, full needs, high-res animation | +| **High** | 90% real | Combat enemies, dialogue NPCs | Real AI, simplified needs, standard animation | +| **Medium** | 50% real / 50% fake | Visible background NPCs | State machines, no needs, scripted paths, LOD animation | +| **Low** | 90% fake | Distant crowd, far traffic | Fake AI, no needs, waypoint movement, simple animation | +| **Minimal** | 100% fake | Off-screen entities | Statistical simulation, no individual updates | + +### Practical Application + +When designing ANY system, ask: +1. **How close can the player get?** (distance-based scrutiny) +2. **How long will they observe?** (time-based scrutiny) +3. **Can they interact?** (interaction-based scrutiny) +4. **Does it affect gameplay?** (relevance-based scrutiny) + +Then allocate simulation budget based on MAXIMUM scrutiny level. + +### Example: City Builder NPCs + +**Scenario**: City with 100 NPCs, player can zoom in/out and click NPCs. + +**Scrutiny Analysis**: +- **10 Important NPCs**: High scrutiny (player knows them by name, clicks often) +- **30 Nearby NPCs**: Medium scrutiny (visible on screen, occasionally clicked) +- **60 Distant NPCs**: Low scrutiny (tiny on screen, rarely clicked) + +**Simulation Strategy**: +```csharp +void UpdateNPC(NPC npc) +{ + float scrutiny = CalculateScrutiny(npc); + + if (scrutiny > 0.8f) // High scrutiny + { + UpdateFullSimulation(npc); // 0.5ms per NPC + } + else if (scrutiny > 0.4f) // Medium scrutiny + { + UpdateHybridSimulation(npc); // 0.05ms per NPC + } + else if (scrutiny > 0.1f) // Low scrutiny + { + UpdateFakeSimulation(npc); // 0.005ms per NPC + } + else // Minimal scrutiny + { + // Don't update, or bulk statistical update + } +} + +float CalculateScrutiny(NPC npc) +{ + float distance = Vector3.Distance(camera.position, npc.position); + float visibility = IsVisible(npc) ? 1.0f : 0.1f; + float interaction = npc.isImportant ? 1.5f : 1.0f; + + // Closer = higher scrutiny + float distanceScore = 1.0f / (1.0f + distance / 50.0f); + + return distanceScore * visibility * interaction; +} +``` + +**Result**: +- 10 important NPCs: 10 × 0.5ms = 5ms +- 30 nearby NPCs: 30 × 0.05ms = 1.5ms +- 60 distant NPCs: 60 × 0.005ms = 0.3ms +- **Total**: 6.8ms (fits in budget) + +Compare to naïve approach: 100 × 0.5ms = 50ms (3× frame budget) + + +## CORE CONCEPT #2: Gameplay Relevance + +The second most important factor: **Does this affect player decisions or outcomes?** + +### Relevance Hierarchy + +``` +CRITICAL TO GAMEPLAY +│ Directly affects win/lose, progression, or core decisions +│ Examples: Enemy health, ammo count, quest state +│ Strategy: ALWAYS SIMULATE (never fake) +│ +SIGNIFICANT TO GAMEPLAY +│ Affects player choices or secondary goals +│ Examples: NPC happiness (affects quests), traffic (blocks player) +│ Strategy: SIMULATE when relevant, fake when not +│ +COSMETIC (OBSERVABLE) +│ Visible to player but doesn't affect gameplay +│ Examples: Crowd animations, ambient wildlife, background traffic +│ Strategy: FAKE heavily, simulate minimally +│ +COSMETIC (UNOBSERVABLE) +│ Exists for "realism" but player rarely sees +│ Examples: NPC sleep schedules, off-screen animals, distant city lights +│ Strategy: FULLY FAKE or remove entirely +│ +``` + +### Relevance Assessment Questions + +For every simulation system, ask: + +1. **Does it affect win/lose?** + - YES → Simulate accurately + - NO → Continue to Q2 + +2. **Does it affect player decisions?** + - YES → Simulate when decision is active + - NO → Continue to Q3 + +3. **Can the player observe it?** + - YES → Fake convincingly + - NO → Continue to Q4 + +4. **Does it affect observable systems?** + - YES → Fake with minimal updates + - NO → Remove or use statistics + +### Example: NPC Needs System + +**System**: NPCs have hunger, energy, social, hygiene needs. + +**Relevance Analysis**: + +| Need | Affects Gameplay? | Observable? | Relevance | Strategy | +|------|------------------|-------------|-----------|----------| +| **Hunger** | YES (unhappy NPCs leave city) | YES (eating animation) | SIGNIFICANT | Simulate (tick-based, not frame-based) | +| **Energy** | NO (doesn't affect anything) | YES (sleeping NPCs) | COSMETIC-OBS | Fake (schedule-based, no simulation) | +| **Social** | NO | YES (chatting NPCs) | COSMETIC-OBS | Fake (pre-assign friends, no dynamics) | +| **Hygiene** | NO | NO (never shown) | COSMETIC-UNOBS | **Remove entirely** | + +**Implementation**: + +```csharp +class NPC +{ + // SIMULATED (affects gameplay) + float hunger; // Decreases every 10 minutes (tick-based) + + // FAKED (cosmetic but observable) + bool isSleeping => GameTime.Hour >= 22 || GameTime.Hour < 6; // Schedule-based + + // FAKED (cosmetic but observable) + List friends; // Pre-assigned at spawn, never changes + + // REMOVED (cosmetic and unobservable) + // float hygiene; // DON'T IMPLEMENT +} + +void Update() +{ + // Only update hunger (gameplay-relevant) + if (Time.frameCount % 600 == 0) // Every 10 seconds at 60fps + { + hunger -= 5.0f; + if (hunger < 20.0f) + StartEatingBehavior(); + } + + // Energy is faked via schedule (no updates needed) + if (isSleeping) + PlaySleepAnimation(); + + // Social is faked (no updates needed) + if (Time.frameCount % 300 == 0) // Every 5 seconds + { + if (friends.Any(f => f.IsNearby())) + PlayChatAnimation(); + } +} +``` + +**Result**: +- Hunger simulation: Believable and affects gameplay +- Energy/Social: Faked but look real +- Hygiene: Removed (didn't add value) +- Performance: 0.01ms per NPC (vs 0.5ms if all simulated) + + +## CORE CONCEPT #3: Performance Budgets + +You can't manage what you don't measure. Start with budgets, design within constraints. + +### Frame Budget Breakdown + +Typical 60 FPS game (16.67ms per frame): + +``` +RENDERING: 8.0ms (48%) ████████████ +SIMULATION: 5.0ms (30%) ███████ + ├─ Physics: 2.0ms ████ + ├─ AI/NPCs: 2.0ms ████ + └─ Game Logic: 1.0ms ██ +UI: 1.5ms (9%) ██ +AUDIO: 1.0ms (6%) ██ +OTHER: 1.17ms (7%) ██ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +TOTAL: 16.67ms (100%) +``` + +### NPC Simulation Budget Example + +**Budget**: 2.0ms for 100 NPCs + +**Allocation**: +``` +IMPORTANT NPCs (10): 1.0ms (50%) 0.100ms each +NEARBY NPCs (30): 0.6ms (30%) 0.020ms each +DISTANT NPCs (60): 0.3ms (15%) 0.005ms each +MANAGER OVERHEAD: 0.1ms (5%) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +TOTAL: 2.0ms +``` + +### Budgeting Process + +**Step 1: Measure baseline** +```csharp +using (new ProfilerScope("NPC_Update")) +{ + foreach (var npc in allNPCs) + npc.Update(); +} +``` + +**Step 2: Identify hotspots** +``` +NPC_Update: 10.5ms ⚠️ (5× over budget) + ├─ Pathfinding: 5.2ms (49%) + ├─ Social Queries: 3.1ms (30%) + ├─ Needs Update: 1.8ms (17%) + └─ Animation: 0.4ms (4%) +``` + +**Step 3: Optimize based on scrutiny** +``` +TARGET: 2.0ms total + +Strategy: + • Pathfinding (5.2ms → 0.5ms): + - Pre-compute paths for background NPCs (90% savings) + - Use waypoints instead of NavMesh for distant NPCs + + • Social Queries (3.1ms → 0.3ms): + - Remove for background NPCs (they don't need dynamic friends) + - Check every 5 seconds, not every frame + + • Needs Update (1.8ms → 0.8ms): + - Tick-based (every 10s) instead of frame-based + - Remove cosmetic needs (hygiene) + + • Animation (0.4ms → 0.4ms): + - Already efficient, keep as-is + +NEW TOTAL: 0.5 + 0.3 + 0.8 + 0.4 = 2.0ms ✅ +``` + +**Step 4: Validate** +```csharp +// Add budget assertions +float startTime = Time.realtimeSinceStartup; +NPCManager.UpdateAll(); +float elapsed = (Time.realtimeSinceStartup - startTime) * 1000f; + +if (elapsed > 2.0f) + Debug.LogWarning($"NPC update exceeded budget: {elapsed:F2}ms"); +``` + +### Budget Allocation Strategy + +**Rule**: Budget should match scrutiny: + +| Scrutiny Level | Budget per Entity | Max Entities at 60 FPS | +|----------------|------------------|----------------------| +| **Extreme** | 1.0-5.0ms | 3-16 | +| **High** | 0.1-1.0ms | 16-160 | +| **Medium** | 0.01-0.1ms | 160-1600 | +| **Low** | 0.001-0.01ms | 1600-16000 | +| **Minimal** | <0.001ms | Unlimited (bulk ops) | + +**Example**: 100 NPCs with 2ms budget +- 10 important: 0.1ms each = 1.0ms total (high scrutiny) +- 90 background: 0.01ms each = 0.9ms total (medium scrutiny) +- Overhead: 0.1ms +- Total: 2.0ms ✅ + + +## CORE CONCEPT #4: Hybrid Approaches (LOD for Simulation) + +The most powerful technique: Don't choose "simulate OR fake"—use BOTH with LOD. + +### Simulation LOD Pyramid + +``` + ╱▔▔▔▔▔▔▔╲ + ╱ ULTRA ╲ Player character + ╱ (100% real) ╲ Boss enemy + ╱________________╲ + ╱ ╲ + ╱ HIGH (90% real) ╲ Important NPCs + ╱______________________╲ Combat enemies + ╱ ╲ +╱ MEDIUM (50% hybrid) ╲ Nearby NPCs +╱____________________________╲ Visible crowd +╱ ╲ +╱ LOW (90% fake) ╲ Distant NPCs +╱__________________________________╲ Far traffic +╱ ╲ +╱ MINIMAL (100% fake/statistical) ╲ Off-screen +╱________________________________________╲ Bulk population +``` + +### Example: NPC Simulation LOD + +**Level 0: ULTRA** (Player inspecting NPC) +```csharp +class UltraDetailNPC +{ + // Full simulation + void Update() + { + UpdateNeedsEveryFrame(); // 0.1ms + UpdateRelationshipsRealTime(); // 0.1ms + UpdateGoalsEveryFrame(); // 0.1ms + UpdatePathfindingRealTime(); // 0.2ms + UpdateAnimationFullRig(); // 0.1ms + // Total: 0.6ms + } +} +``` + +**Level 1: HIGH** (Important NPCs) +```csharp +class HighDetailNPC +{ + // Detailed simulation with optimizations + void Update() + { + if (Time.frameCount % 10 == 0) // 6 fps for needs + UpdateNeedsTick(); // 0.01ms + + if (Time.frameCount % 30 == 0) // 2 fps for relationships + UpdateRelationshipsTick(); // 0.01ms + + UpdateGoalsEveryFrame(); // 0.05ms (simplified) + UpdatePathfindingCached(); // 0.02ms (use cached paths) + UpdateAnimationStandard(); // 0.05ms + // Total: 0.14ms (per frame amortized) + } +} +``` + +**Level 2: MEDIUM** (Nearby NPCs) +```csharp +class MediumDetailNPC +{ + // Hybrid: State machine + minimal updates + void Update() + { + if (Time.frameCount % 60 == 0) // 1 fps for needs + UpdateNeedsStateMachine(); // 0.005ms (just state, not values) + + // No relationships (pre-assigned at spawn) + + UpdateStateMachine(); // 0.01ms (simple FSM) + FollowWaypoints(); // 0.005ms (no pathfinding) + UpdateAnimationLOD(); // 0.01ms + // Total: 0.03ms (per frame amortized) + } +} +``` + +**Level 3: LOW** (Distant NPCs) +```csharp +class LowDetailNPC +{ + // Mostly fake: Scripted behavior + void Update() + { + if (Time.frameCount % 300 == 0) // 0.2 fps + { + AdvanceScriptedPath(); // 0.001ms (just move along spline) + } + + // No needs, no relationships, no AI + UpdateAnimationMinimal(); // 0.005ms (simple walk cycle) + // Total: 0.005ms (per frame amortized) + } +} +``` + +**Level 4: MINIMAL** (Off-screen / Far away) +```csharp +class MinimalDetailNPC +{ + // Fully fake: Statistical or frozen + void Update() + { + // NO INDIVIDUAL UPDATES + // Managed by PopulationManager in bulk + } +} + +class PopulationManager +{ + void Update() + { + // Update entire population statistically + if (Time.frameCount % 600 == 0) // 0.1 fps + { + UpdatePopulationStatistics(); // 0.1ms for ALL minimal NPCs + } + } +} +``` + +### LOD Transition Strategy + +**Smooth Transitions**: Avoid jarring switches between LOD levels. + +```csharp +class AdaptiveNPC +{ + SimulationLevel currentLevel; + float lodDistance = 50f; + + void Update() + { + float distance = Vector3.Distance(Camera.main.transform.position, transform.position); + + // Determine target LOD + SimulationLevel targetLevel; + if (isBeingInspected) + targetLevel = SimulationLevel.Ultra; + else if (isImportant && distance < lodDistance) + targetLevel = SimulationLevel.High; + else if (distance < lodDistance * 2) + targetLevel = SimulationLevel.Medium; + else if (distance < lodDistance * 4) + targetLevel = SimulationLevel.Low; + else + targetLevel = SimulationLevel.Minimal; + + // Smooth transition + if (targetLevel != currentLevel) + { + TransitionToLOD(targetLevel); + currentLevel = targetLevel; + } + + // Update at current level + UpdateAtLevel(currentLevel); + } + + void TransitionToLOD(SimulationLevel newLevel) + { + if (newLevel > currentLevel) // Upgrading + { + // Generate missing state from current state + if (newLevel >= SimulationLevel.High && currentLevel < SimulationLevel.High) + { + // Add needs simulation + needs.hunger = PredictHungerFromTimeAndActivity(); + needs.energy = PredictEnergyFromTimeAndActivity(); + } + } + else // Downgrading + { + // Cache important state, discard details + if (newLevel < SimulationLevel.High) + { + // Freeze needs at current values + cachedHunger = needs.hunger; + needs = null; // GC will collect + } + } + } +} +``` + +**Result**: NPCs smoothly transition between detail levels as player moves camera, with no jarring pops or sudden behavior changes. + + +## CORE CONCEPT #5: Pre-Computation and Caching + +If something is PREDICTABLE, compute it ONCE and reuse. + +### Pre-Computation Opportunities + +**Pattern**: Behavior repeats or follows patterns → pre-compute. + +**Example 1: Daily NPC Paths** + +NPCs follow same routes daily: +- Home → Workplace (8am) +- Workplace → Tavern (5pm) +- Tavern → Home (10pm) + +**Bad** (recompute every day): +```csharp +void Update() +{ + if (GameTime.Hour == 8 && !hasComputedWorkPath) + { + path = Pathfinding.FindPath(home, workplace); // 2ms + hasComputedWorkPath = true; + } +} +// Cost: 2ms × 100 NPCs = 200ms spike at 8am +``` + +**Good** (pre-compute at spawn): +```csharp +void Start() +{ + // Compute all paths once + pathToWork = Pathfinding.FindPath(home, workplace); + pathToTavern = Pathfinding.FindPath(workplace, tavern); + pathToHome = Pathfinding.FindPath(tavern, home); +} + +void Update() +{ + if (GameTime.Hour == 8) + FollowPath(pathToWork); // 0.001ms (just interpolate) +} +// Cost: 2ms × 100 NPCs at spawn (spread over time), 0.1ms per frame +``` + +**Savings**: 200ms → 0.1ms (2000× faster) + +**Example 2: Crowd Animation** + +Background NPCs walk in circles. Instead of unique animations: + +```csharp +// Pre-compute animation offsets at spawn +void Start() +{ + animationOffset = Random.Range(0f, 1f); // Randomize start frame +} + +void Update() +{ + // All NPCs use same animation, different offsets + float animTime = (Time.time + animationOffset) % animationClip.length; + animator.Play("Walk", 0, animTime); +} +``` + +Result: 100 NPCs use 1 animation clip, near-zero cost. + +### Caching Strategy + +**Pattern**: Expensive computation with infrequent changes → cache result. + +**Example: Social Proximity Queries** + +Finding nearby NPCs for social interactions: + +**Bad** (compute every frame): +```csharp +void Update() +{ + Collider[] nearby = Physics.OverlapSphere(position, socialRadius); // 0.5ms + foreach (var col in nearby) + { + NPC other = col.GetComponent(); + InteractWith(other); + } +} +// Cost: 0.5ms × 100 NPCs = 50ms +``` + +**Good** (cache and refresh slowly): +```csharp +List cachedNearby = new List(); +float cacheRefreshInterval = 5f; // Refresh every 5 seconds + +void Update() +{ + if (Time.time > lastCacheRefresh + cacheRefreshInterval) + { + cachedNearby = FindNearbyNPCs(); // 0.5ms + lastCacheRefresh = Time.time; + } + + // Use cached list (free) + foreach (var other in cachedNearby) + InteractWith(other); +} +// Cost: 0.5ms × 100 NPCs / (5 seconds × 60 fps) = 0.16ms per frame +``` + +**Savings**: 50ms → 0.16ms (300× faster) + +### Pre-Computation Checklist + +Ask for EVERY system: +1. ☐ Does this repeat? → Pre-compute once, replay +2. ☐ Can this be computed offline? → Bake into assets +3. ☐ Does this change slowly? → Cache and refresh infrequently +4. ☐ Is this deterministic? → Compute on-demand, cache result +5. ☐ Can this use lookup tables? → Replace computation with table lookup + + +## CORE CONCEPT #6: Statistical and Aggregate Simulation + +When you have MANY similar entities, simulate them as a GROUP, not individuals. + +### Statistical Simulation Pattern + +**Concept**: Instead of tracking 1000 individual NPCs, track the POPULATION distribution. + +**Example: City Population** + +**Naïve Approach** (1000 individual NPCs): +```csharp +class NPC +{ + Vector3 position; + Activity currentActivity; + float hunger, energy, happiness; + + void Update() + { + UpdateNeeds(); + UpdateActivity(); + UpdatePosition(); + } +} + +// 1000 NPCs × 0.1ms each = 100ms +``` + +**Statistical Approach** (aggregate population): +```csharp +class CityPopulation +{ + int totalPopulation = 1000; + + // Distribution of activities + Dictionary activityDistribution = new Dictionary() + { + { Activity.Working, 0.0f }, + { Activity.Eating, 0.0f }, + { Activity.Sleeping, 0.0f }, + { Activity.Socializing, 0.0f }, + }; + + // Average needs + float averageHunger = 50f; + float averageHappiness = 70f; + + void Update() + { + // Update distribution based on time of day + float hour = GameTime.Hour; + + if (hour >= 8 && hour < 17) // Work hours + { + activityDistribution[Activity.Working] = 0.7f; + activityDistribution[Activity.Eating] = 0.1f; + activityDistribution[Activity.Socializing] = 0.2f; + } + else if (hour >= 22 || hour < 6) // Night + { + activityDistribution[Activity.Sleeping] = 0.9f; + activityDistribution[Activity.Working] = 0.0f; + } + + // Update average needs (simple model) + averageHunger -= 1f * Time.deltaTime; + if (activityDistribution[Activity.Eating] > 0.1f) + averageHunger += 5f * Time.deltaTime; + + averageHappiness = Mathf.Lerp(averageHappiness, 70f, Time.deltaTime * 0.1f); + } +} + +// Cost: 0.01ms total (10,000× faster than 1000 individual NPCs) +``` + +**Visualization**: Spawn visible NPCs to match distribution: +```csharp +class PopulationVisualizer +{ + List visibleNPCs = new List(); + int maxVisibleNPCs = 50; + + void Update() + { + // Spawn/despawn NPCs to match statistical distribution + int targetWorking = (int)(maxVisibleNPCs * population.activityDistribution[Activity.Working]); + + int currentWorking = visibleNPCs.Count(n => n.activity == Activity.Working); + + if (currentWorking < targetWorking) + SpawnWorkingNPC(); + else if (currentWorking > targetWorking) + DespawnWorkingNPC(); + } +} +``` + +**Result**: City FEELS like 1000 people, but only simulates 50 visible NPCs + aggregate stats. + +### Aggregate Physics Example + +**Scenario**: 500 leaves falling from tree. + +**Individual Physics** (500 rigidbodies): +```csharp +foreach (var leaf in leaves) +{ + leaf.rigidbody.velocity += Physics.gravity * Time.deltaTime; + leaf.rigidbody.AddForce(wind); +} +// Cost: 10ms+ (physics engine overhead) +``` + +**Aggregate Approach** (particle system + fake physics): +```csharp +class LeafParticleSystem +{ + ParticleSystem particles; + + void Start() + { + particles.maxParticles = 500; + particles.gravityModifier = 1.0f; + + // Use particle system's built-in physics (GPU-accelerated) + var velocityOverLifetime = particles.velocityOverLifetime; + velocityOverLifetime.enabled = true; + velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-1f, 1f); // Wind variation + } +} +// Cost: 0.1ms (50× faster, GPU-accelerated) +``` + +**Result**: Leaves look real, but use particles instead of individual physics. + +### When to Use Statistical Simulation + +Use statistical simulation when: +- ✅ Entities are numerous (100+) +- ✅ Entities are similar (same type/behavior) +- ✅ Individual state doesn't affect gameplay +- ✅ Player observes aggregate, not individuals +- ✅ Performance is constrained + +Don't use when: +- ❌ Player can inspect individuals +- ❌ Individual state affects gameplay +- ❌ Entities have unique behaviors +- ❌ Small number of entities (<10) + + +## CORE CONCEPT #7: Cognitive Tricks and Illusions + +The human brain is TERRIBLE at noticing details. Exploit this. + +### Perceptual Limits + +**Fact 1**: Humans can track ~4-7 objects simultaneously. +- **Exploit**: Only simulate 5-10 NPCs in detail, rest can be simple + +**Fact 2**: Humans notice motion more than detail. +- **Exploit**: Animate everything, even if behavior is fake + +**Fact 3**: Humans fill in gaps (pareidolia). +- **Exploit**: Suggest behavior, let player imagine the rest + +**Fact 4**: Humans notice sudden changes, not gradual ones. +- **Exploit**: Fade transitions, avoid instant pops + +**Fact 5**: Humans notice center-screen more than periphery. +- **Exploit**: Focus simulation on camera center + +### Technique #1: Theater of the Mind + +**Concept**: Show hints of a system, let player imagine it's fully simulated. + +**Example: Off-screen Combat** + +**Full Simulation**: +```csharp +// Simulate entire battle off-screen +foreach (var unit in offScreenUnits) +{ + unit.FindTarget(); + unit.Attack(); + unit.TakeDamage(); +} +// Cost: High +``` + +**Theater of Mind**: +```csharp +// Just play sound effects and show particles +if (battleIsHappening) +{ + if (Random.value < 0.1f) // 10% chance per frame + PlayRandomCombatSound(); + + SpawnParticlesBeyondHill(); +} +// Cost: Negligible +``` + +**Result**: Player hears fighting, sees particles, assumes battle is happening. No actual simulation needed. + +### Technique #2: Randomization Hides Patterns + +**Concept**: Random variation makes simple systems feel complex. + +**Example: NPC Idle Behavior** + +**Simple FSM**: +```csharp +void Update() +{ + if (state == Idle) + { + // Just stand still + animator.Play("Idle"); + } +} +// Looks robotic +``` + +**With Randomization**: +```csharp +void Update() +{ + if (state == Idle) + { + // Randomly look around, shift weight, scratch head + if (Random.value < 0.01f) // 1% chance per frame + { + int randomAction = Random.Range(0, 3); + switch (randomAction) + { + case 0: animator.Play("LookAround"); break; + case 1: animator.Play("ShiftWeight"); break; + case 2: animator.Play("ScratchHead"); break; + } + } + } +} +// Looks alive +``` + +**Result**: Same simple FSM, but feels much more realistic. + +### Technique #3: Persistence of Vision + +**Concept**: Objects that briefly disappear aren't scrutinized when they return. + +**Example: NPC Teleportation** + +**Problem**: NPC needs to move 500m, pathfinding is expensive. + +**Solution**: Hide, teleport, reveal. + +```csharp +void TravelToLocation(Vector3 destination) +{ + // Walk behind building + WalkTo(nearestOccluder); + + // When occluded, teleport + if (IsOccluded()) + { + transform.position = destination; + } + + // Walk out from destination +} +``` + +**Result**: Player sees NPC walk behind building, then emerge at destination. Brain fills in the gap. + +### Technique #4: Focal Point Misdirection + +**Concept**: Players look where you direct them, not at background. + +**Example: Crowd During Cutscene** + +During cutscene, player watches characters talking: + +```csharp +void PlayCutscene() +{ + // Focus camera on speakers + Camera.main.FocusOn(speaker); + + // Background crowd? Freeze them. + foreach (var npc in backgroundNPCs) + { + npc.Freeze(); // No simulation + } +} +``` + +**Result**: Player never notices background is frozen because they're watching speakers. + +### Technique #5: Temporal Aliasing + +**Concept**: If something changes slower than perception threshold, fake it. + +**Example: Distant Vehicle Traffic** + +Far-away cars (200m+) change slowly from player POV: + +```csharp +void UpdateDistantTraffic() +{ + // Only update every 2 seconds + if (Time.frameCount % 120 == 0) + { + foreach (var car in distantCars) + { + // Teleport along path (2-second jumps) + car.position += car.velocity * 2.0f; + } + } +} +``` + +**Result**: At 200m distance, 2-second jumps are imperceptible. Saves 119/120 frames of updates. + + +## DECISION FRAMEWORK #1: Scrutiny-Based LOD + +**Use this framework for EVERY simulation system.** + +### Step 1: Identify Scrutiny Levels + +For each entity type, determine scrutiny levels: + +``` +EXAMPLE: City Builder NPCs + +Scrutiny Levels: + • EXTREME: Player clicking "Inspect" button (shows detailed stats) + • HIGH: Player-selected NPCs (mayor, quest givers) + • MEDIUM: On-screen NPCs within 50m + • LOW: On-screen NPCs beyond 50m + • MINIMAL: Off-screen NPCs +``` + +### Step 2: Assign Simulation Tiers + +For each scrutiny level, define simulation tier: + +``` +EXAMPLE TIERS: + +EXTREME Scrutiny: + ✓ Full needs simulation (hunger, energy, social, updated every frame) + ✓ Full pathfinding (A* with dynamic obstacles) + ✓ Full social system (track relationships, update in real-time) + ✓ Full animation (all body parts, IK, facial expressions) + +HIGH Scrutiny: + ✓ Tick-based needs (updated every 10 seconds) + ✓ Cached pathfinding (pre-computed paths, no A*) + ✓ Simplified social (static friend list) + ✓ Standard animation (body only, no IK/face) + +MEDIUM Scrutiny: + ✓ State-machine behavior (no needs simulation) + ✓ Waypoint following (no pathfinding) + ✓ No social system + ✓ LOD animation (lower frame rate) + +LOW Scrutiny: + ✓ Scripted movement (spline-based) + ✓ No AI + ✓ Minimal animation (simple walk cycle) + +MINIMAL Scrutiny: + ✓ Statistical (bulk population model) + ✓ No individual entities +``` + +### Step 3: Implement LOD Thresholds + +```csharp +SimulationTier DetermineSimulationTier(NPC npc) +{ + // EXTREME: Player inspecting + if (npc == PlayerSelection.inspectedNPC) + return SimulationTier.Extreme; + + float distance = Vector3.Distance(npc.position, Camera.main.transform.position); + bool isVisible = IsVisibleToCamera(npc); + + // HIGH: Important and visible + if (npc.isImportant && isVisible && distance < 50f) + return SimulationTier.High; + + // MEDIUM: Visible and nearby + if (isVisible && distance < 50f) + return SimulationTier.Medium; + + // LOW: Visible but distant + if (isVisible && distance < 200f) + return SimulationTier.Low; + + // MINIMAL: Off-screen or very distant + return SimulationTier.Minimal; +} +``` + +### Step 4: Update Based on Tier + +```csharp +void Update() +{ + SimulationTier tier = DetermineSimulationTier(this); + + switch (tier) + { + case SimulationTier.Extreme: + UpdateFullSimulation(); + break; + case SimulationTier.High: + UpdateHighDetailSimulation(); + break; + case SimulationTier.Medium: + UpdateMediumDetailSimulation(); + break; + case SimulationTier.Low: + UpdateLowDetailSimulation(); + break; + case SimulationTier.Minimal: + // No update (handled by PopulationManager) + break; + } +} +``` + + +## DECISION FRAMEWORK #2: Gameplay Relevance + +### Step 1: Classify Systems + +For every simulation system, classify: + +``` +SYSTEM: NPC Hunger + +Questions: + Q1: Does it affect win/lose conditions? + → NO + + Q2: Does it affect player progression? + → YES (unhappy NPCs leave city → lose citizens → fail) + + Q3: Can player directly interact with it? + → YES (player can build food sources) + +CLASSIFICATION: GAMEPLAY-CRITICAL +STRATEGY: Simulate accurately +``` + +``` +SYSTEM: NPC Sleep Schedules + +Questions: + Q1: Does it affect win/lose conditions? + → NO + + Q2: Does it affect player progression? + → NO + + Q3: Can player directly interact with it? + → NO + + Q4: Is it observable? + → YES (NPCs visibly sleep at night) + +CLASSIFICATION: COSMETIC-OBSERVABLE +STRATEGY: Fake (schedule-based, no simulation) +``` + +### Step 2: Apply Strategy Matrix + +| Classification | Strategy | Implementation | +|---------------|----------|----------------| +| **Gameplay-Critical** | ALWAYS SIMULATE | Full accuracy, no shortcuts | +| **Gameplay-Significant** | SIMULATE WHEN RELEVANT | Full sim when player cares, fake otherwise | +| **Cosmetic-Observable** | FAKE CONVINCINGLY | No sim, just appearance | +| **Cosmetic-Unobservable** | REMOVE OR FAKE | Cut it or use statistics | + +### Step 3: Implementation Examples + +**Gameplay-Critical** (Enemy Health): +```csharp +class Enemy +{ + float health = 100f; + + void TakeDamage(float damage) + { + health -= damage; // Precise calculation + if (health <= 0) + Die(); + } +} +``` + +**Cosmetic-Observable** (Background Birds): +```csharp +class BirdFlock +{ + void Update() + { + // Fake: Use boids algorithm (cheap), not real physics + foreach (var bird in birds) + { + bird.position += bird.velocity * Time.deltaTime; + bird.velocity += CalculateBoidsForce(bird); // Simple math + } + } +} +``` + +**Cosmetic-Unobservable** (Distant City Lights): +```csharp +class CityLights +{ + void Update() + { + // Remove: Don't simulate, just use emissive texture + // Lights turn on/off based on time of day (shader-driven) + } +} +``` + + +## DECISION FRAMEWORK #3: Performance-First Design + +### Step 1: Start with Budget + +**ALWAYS start with performance budget, then design within constraints.** + +``` +EXAMPLE: RTS with 500 units + +Frame Budget: 16.67ms (60 FPS) +Unit Simulation Budget: 4ms + +Budget per Unit: 4ms / 500 = 0.008ms = 8 microseconds + +This is VERY tight. Can't afford complex AI. + +Design Constraints: + • No pathfinding per frame (too expensive) + • No complex collision checks + • Simple state machines only + • Bulk operations where possible +``` + +### Step 2: Profile Early + +Don't wait until the end to optimize. Profile DURING design. + +```csharp +void PrototypeSystem() +{ + // Create minimal version + for (int i = 0; i < 500; i++) + { + units.Add(new Unit()); + } + + // Profile immediately + using (new ProfilerScope("Unit_Update")) + { + foreach (var unit in units) + { + unit.Update(); + } + } + + // Check results + // If > 4ms, simplify BEFORE adding more features +} +``` + +### Step 3: Identify Bottlenecks + +``` +Profile Results: + Unit_Update: 6.5ms ⚠️ (1.6× over budget) + ├─ Pathfinding: 3.2ms (49%) ← BOTTLENECK + ├─ Combat: 1.8ms (28%) + ├─ Animation: 0.9ms (14%) + └─ Other: 0.6ms (9%) + +Action: Fix pathfinding first (biggest impact) +``` + +### Step 4: Optimize Bottlenecks + +**Before** (3.2ms for pathfinding): +```csharp +void Update() +{ + if (needsNewPath) + { + path = Pathfinding.FindPath(position, target); // 3.2ms + } +} +``` + +**After** (0.1ms for pathfinding): +```csharp +// Time-slice: Only path 10 units per frame +static Queue pathfindingQueue = new Queue(); +static int maxPathsPerFrame = 10; + +void Update() +{ + if (needsNewPath && !pathfindingQueue.Contains(this)) + { + pathfindingQueue.Enqueue(this); + } +} + +static void UpdatePathfinding() +{ + for (int i = 0; i < maxPathsPerFrame && pathfindingQueue.Count > 0; i++) + { + Unit unit = pathfindingQueue.Dequeue(); + unit.path = Pathfinding.FindPath(unit.position, unit.target); + } +} +// 500 units need paths → 50 frames to complete all (acceptable) +// Cost per frame: 10 paths × 0.01ms = 0.1ms +``` + +**Result**: 3.2ms → 0.1ms (32× faster) + +### Step 5: Iterate + +``` +NEW Profile Results: + Unit_Update: 2.4ms ✅ (within budget!) + ├─ Combat: 1.8ms (75%) + ├─ Animation: 0.5ms (21%) + ├─ Pathfinding: 0.1ms (4%) + +Budget Remaining: 4ms - 2.4ms = 1.6ms + +Can now add more features within remaining budget. +``` + + +## DECISION FRAMEWORK #4: Pragmatic Trade-offs + +Balance perfection vs time-to-ship. + +### The Trade-off Triangle + +``` + QUALITY + / \\ + / \\ + / \\ + / \\ + / PICK \\ + / TWO \\ + /_____________\\ + SPEED SCOPE +``` + +**Reality**: You can't have all three. Choose wisely. + +### Example Scenarios + +**Scenario 1: Prototype (Speed + Scope)** +``` +Goal: Prove concept in 2 weeks +Strategy: Sacrifice quality + • Fake everything possible + • Hard-code values + • Skip edge cases + • No optimization + • Placeholder art +``` + +**Scenario 2: Production (Quality + Scope)** +``` +Goal: Ship polished game in 1 year +Strategy: Take time needed + • Implement properly + • Optimize carefully + • Handle edge cases + • Polish visuals + • Iterate on feedback +``` + +**Scenario 3: Jam/Demo (Speed + Quality)** +``` +Goal: Impressive demo in 48 hours +Strategy: Reduce scope aggressively + • Single level + • One mechanic + • Fake everything not shown + • Polish what player sees + • Cut everything else +``` + +### Decision Matrix + +| Context | Time Budget | Quality Target | Strategy | +|---------|-------------|---------------|----------| +| **Prototype** | 1-2 weeks | Working, ugly | Fake everything, prove concept | +| **Vertical Slice** | 1-2 months | Polished sample | Full quality for slice, fake rest | +| **Alpha** | 3-6 months | Feature-complete | Broad features, low polish | +| **Beta** | 6-12 months | Optimized | Optimize critical paths, fake background | +| **Gold** | 12+ months | Shippable | Polish everything visible | + +### Pragmatic Simulation Choices + +**Prototype Stage**: +```csharp +// FAKE: Hard-coded schedule +void Update() +{ + if (GameTime.Hour == 8) + transform.position = workplacePosition; // Teleport! +} +``` + +**Alpha Stage**: +```csharp +// BASIC SIMULATION: Simple pathfinding +void Update() +{ + if (GameTime.Hour == 8 && !atWorkplace) + agent.SetDestination(workplacePosition); +} +``` + +**Beta Stage**: +```csharp +// OPTIMIZED SIMULATION: Pre-computed paths +void Update() +{ + if (GameTime.Hour == 8 && !atWorkplace) + FollowPrecomputedPath(pathToWork); +} +``` + +**Gold Stage**: +```csharp +// POLISHED: LOD system, smooth transitions +void Update() +{ + SimulationTier tier = DetermineSimulationTier(); + if (GameTime.Hour == 8 && !atWorkplace) + { + if (tier >= SimulationTier.High) + FollowPrecomputedPath(pathToWork); + else + TeleportToWork(); // Still fake for low-detail NPCs! + } +} +``` + +**Key Insight**: Even at Gold, background NPCs still fake (teleport). Polish doesn't mean simulate everything—it means simulate what matters. + + +## IMPLEMENTATION PATTERN #1: Tick-Based Updates + +**Problem**: Systems update every frame but change slowly. + +**Solution**: Update on a schedule, not every frame. + +### Basic Tick System + +```csharp +public class TickManager : MonoBehaviour +{ + public static TickManager Instance; + + public event Action OnSlowTick; // 1 Hz (every 1 second) + public event Action OnMediumTick; // 10 Hz (every 0.1 seconds) + public event Action OnFastTick; // 30 Hz (every 0.033 seconds) + + private float slowTickInterval = 1.0f; + private float mediumTickInterval = 0.1f; + private float fastTickInterval = 0.033f; + + private float lastSlowTick, lastMediumTick, lastFastTick; + + void Update() + { + float time = Time.time; + + if (time - lastSlowTick >= slowTickInterval) + { + OnSlowTick?.Invoke(); + lastSlowTick = time; + } + + if (time - lastMediumTick >= mediumTickInterval) + { + OnMediumTick?.Invoke(); + lastMediumTick = time; + } + + if (time - lastFastTick >= fastTickInterval) + { + OnFastTick?.Invoke(); + lastFastTick = time; + } + } +} +``` + +### Usage Example + +```csharp +class NPC : MonoBehaviour +{ + void Start() + { + // Subscribe to appropriate tick rate + TickManager.Instance.OnSlowTick += UpdateNeeds; + TickManager.Instance.OnMediumTick += UpdateBehavior; + } + + void UpdateNeeds() + { + // Slow-changing systems (1 Hz) + hunger -= 5.0f; + energy -= 3.0f; + } + + void UpdateBehavior() + { + // Medium-speed systems (10 Hz) + UpdateStateMachine(); + CheckGoals(); + } + + void Update() + { + // Fast systems (every frame) + UpdateAnimation(); + UpdateVisuals(); + } +} +``` + +**Performance Gain**: 3 systems × 60 FPS = 180 updates/sec → (1 + 10 + 60) = 71 updates/sec (2.5× faster) + + +## IMPLEMENTATION PATTERN #2: Time-Slicing + +**Problem**: 100 entities need expensive updates, but not every frame. + +**Solution**: Stagger updates across multiple frames. + +### Time-Slicing System + +```csharp +public class TimeSlicedUpdater where T : class +{ + private List entities = new List(); + private int entitiesPerFrame; + private int currentIndex = 0; + + public TimeSlicedUpdater(int entitiesPerFrame) + { + this.entitiesPerFrame = entitiesPerFrame; + } + + public void Register(T entity) + { + entities.Add(entity); + } + + public void Unregister(T entity) + { + entities.Remove(entity); + } + + public void Update(Action updateFunc) + { + int count = Mathf.Min(entitiesPerFrame, entities.Count); + + for (int i = 0; i < count; i++) + { + if (currentIndex >= entities.Count) + currentIndex = 0; + + updateFunc(entities[currentIndex]); + currentIndex++; + } + } +} +``` + +### Usage Example + +```csharp +public class NPCManager : MonoBehaviour +{ + private TimeSlicedUpdater npcUpdater; + + void Start() + { + // Update 10 NPCs per frame (100 NPCs = 10 frames for full update) + npcUpdater = new TimeSlicedUpdater(10); + + foreach (var npc in allNPCs) + npcUpdater.Register(npc); + } + + void Update() + { + npcUpdater.Update(npc => + { + npc.UpdateExpensiveLogic(); + }); + } +} +``` + +**Performance Gain**: 100 NPCs × 2ms = 200ms → 10 NPCs × 2ms = 20ms (10× faster) + +**Trade-off**: Each NPC updates every 10 frames instead of every frame. For slow-changing systems, this is imperceptible. + + +## IMPLEMENTATION PATTERN #3: Lazy State Generation + +**Problem**: Storing full state for all entities wastes memory. + +**Solution**: Generate state on-demand when needed. + +### Lazy State System + +```csharp +class BackgroundNPC +{ + // Minimal stored state + public int id; + public Vector3 position; + public Activity currentActivity; + + // Expensive state (generated on-demand) + private NPCDetailedState _cachedDetails = null; + + public NPCDetailedState GetDetails() + { + if (_cachedDetails == null) + { + _cachedDetails = GenerateDetails(); + } + return _cachedDetails; + } + + private NPCDetailedState GenerateDetails() + { + // Procedurally generate detailed state + return new NPCDetailedState + { + name = NameGenerator.Generate(id), + backstory = StoryGenerator.Generate(id), + friends = FriendGenerator.GenerateFriends(id, position), + hunger = PredictHunger(currentActivity, GameTime.Hour), + energy = PredictEnergy(currentActivity, GameTime.Hour), + personality = PersonalityGenerator.Generate(id), + }; + } + + public void InvalidateCache() + { + _cachedDetails = null; // Clear cache when state changes significantly + } +} +``` + +### Predictive State Generation + +```csharp +float PredictHunger(Activity activity, float hour) +{ + // Use time-of-day to predict plausible hunger value + float hoursSinceLastMeal = hour - 12.0f; // Assume lunch at 12pm + if (hoursSinceLastMeal < 0) + hoursSinceLastMeal += 24; + + float hunger = 100 - (hoursSinceLastMeal * 5.0f); + return Mathf.Clamp(hunger, 0, 100); +} +``` + +**Result**: NPC appears to have persistent state, but it's generated on-demand using seed (id) and current time. + + +## IMPLEMENTATION PATTERN #4: State Prediction + +**Problem**: Background NPCs need plausible state when inspected. + +**Solution**: Predict what state SHOULD be based on context. + +### Prediction Functions + +```csharp +class NPCStatePredictor +{ + public static float PredictHunger(NPC npc, float currentHour) + { + // Hunger decreases linearly, resets at meal times + float hungerDecayRate = 5.0f; // per hour + + // Determine time since last meal + float[] mealTimes = { 7.0f, 12.0f, 19.0f }; // Breakfast, lunch, dinner + float timeSinceLastMeal = CalculateTimeSinceLastEvent(currentHour, mealTimes); + + float hunger = 100 - (timeSinceLastMeal * hungerDecayRate); + return Mathf.Clamp(hunger, 0, 100); + } + + public static float PredictEnergy(NPC npc, float currentHour) + { + // Energy low during day, high after sleep + if (currentHour >= 6 && currentHour < 22) // Awake + { + float hoursAwake = currentHour - 6; + return Mathf.Clamp(100 - (hoursAwake * 6.25f), 20, 100); // 20% min + } + else // Sleeping + { + return 100f; + } + } + + public static Activity PredictActivity(NPC npc, float currentHour) + { + // Simple schedule-based prediction + if (currentHour >= 22 || currentHour < 6) + return Activity.Sleeping; + else if (currentHour >= 8 && currentHour < 17) + return Activity.Working; + else if (currentHour >= 18 && currentHour < 22) + return Activity.Socializing; + else + return Activity.Eating; + } + + private static float CalculateTimeSinceLastEvent(float currentTime, float[] eventTimes) + { + float minDelta = float.MaxValue; + foreach (float eventTime in eventTimes) + { + float delta = currentTime - eventTime; + if (delta < 0) delta += 24; // Wrap around + + if (delta < minDelta) + minDelta = delta; + } + return minDelta; + } +} +``` + +### Usage + +```csharp +void OnPlayerInspectNPC(NPC npc) +{ + if (npc.simulationLevel == SimLevel.Fake) + { + // Generate plausible state + npc.hunger = NPCStatePredictor.PredictHunger(npc, GameTime.Hour); + npc.energy = NPCStatePredictor.PredictEnergy(npc, GameTime.Hour); + npc.currentActivity = NPCStatePredictor.PredictActivity(npc, GameTime.Hour); + + // Upgrade to real simulation + npc.simulationLevel = SimLevel.Real; + } + + // Show UI with generated state + UI.ShowNPCDetails(npc); +} +``` + +**Result**: Player inspects background NPC, sees plausible stats that match time-of-day. NPC appears to have been simulated all along. + + +## IMPLEMENTATION PATTERN #5: Pre-Computed Paths + +**Problem**: NPCs follow predictable routes, but pathfinding is expensive. + +**Solution**: Compute paths once, store as waypoints, replay. + +### Pre-Computation System + +```csharp +class NPCPathDatabase : MonoBehaviour +{ + private Dictionary<(Vector3, Vector3), Path> pathCache = new Dictionary<(Vector3, Vector3), Path>(); + + public void PrecomputeCommonPaths() + { + // Pre-compute paths between common locations + var homes = FindObjectsOfType(); + var workplaces = FindObjectsOfType(); + var taverns = FindObjectsOfType(); + + foreach (var home in homes) + { + foreach (var workplace in workplaces) + { + Path path = Pathfinding.FindPath(home.position, workplace.position); + pathCache[(home.position, workplace.position)] = path; + } + + foreach (var tavern in taverns) + { + Path path = Pathfinding.FindPath(home.position, tavern.position); + pathCache[(home.position, tavern.position)] = path; + } + } + + Debug.Log($"Pre-computed {pathCache.Count} paths"); + } + + public Path GetPath(Vector3 from, Vector3 to) + { + // Round to nearest grid cell (for cache hits) + Vector3 fromKey = RoundToGrid(from); + Vector3 toKey = RoundToGrid(to); + + if (pathCache.TryGetValue((fromKey, toKey), out Path path)) + { + return path; + } + else + { + // Fallback: compute on-demand (rare) + return Pathfinding.FindPath(from, to); + } + } + + private Vector3 RoundToGrid(Vector3 pos) + { + float gridSize = 5f; + return new Vector3( + Mathf.Round(pos.x / gridSize) * gridSize, + pos.y, + Mathf.Round(pos.z / gridSize) * gridSize + ); + } +} +``` + +### Path Following + +```csharp +class NPC : MonoBehaviour +{ + private Path currentPath; + private int waypointIndex = 0; + + public void StartPath(Vector3 destination) + { + currentPath = NPCPathDatabase.Instance.GetPath(transform.position, destination); + waypointIndex = 0; + } + + void Update() + { + if (currentPath != null && waypointIndex < currentPath.waypoints.Count) + { + Vector3 target = currentPath.waypoints[waypointIndex]; + transform.position = Vector3.MoveTowards(transform.position, target, speed * Time.deltaTime); + + if (Vector3.Distance(transform.position, target) < 0.5f) + { + waypointIndex++; + } + } + } +} +``` + +**Performance Gain**: Pathfinding cost moves from runtime (2ms per path) to startup (pre-computed once). + + +## IMPLEMENTATION PATTERN #6: Event-Driven State Changes + +**Problem**: Polling for state changes wastes CPU. + +**Solution**: Use events to trigger updates only when needed. + +### Event System + +```csharp +public class GameClock : MonoBehaviour +{ + public static GameClock Instance; + + public event Action OnHourChanged; + public event Action OnDayChanged; + + private float currentHour = 6f; + private int lastHourTriggered = 6; + + void Update() + { + currentHour += Time.deltaTime / 60f; // 1 game hour = 1 real minute + + if (currentHour >= 24f) + { + currentHour -= 24f; + OnDayChanged?.Invoke(currentHour); + } + + int hourInt = Mathf.FloorToInt(currentHour); + if (hourInt != lastHourTriggered) + { + OnHourChanged?.Invoke(hourInt); + lastHourTriggered = hourInt; + } + } +} +``` + +### Event-Driven NPC + +```csharp +class NPC : MonoBehaviour +{ + void Start() + { + // Subscribe to time events + GameClock.Instance.OnHourChanged += OnHourChanged; + } + + void OnHourChanged(int hour) + { + // React to specific hours + switch (hour) + { + case 6: + WakeUp(); + break; + case 8: + GoToWork(); + break; + case 17: + LeaveWork(); + break; + case 22: + GoToSleep(); + break; + } + } + + // No Update() needed for schedule! +} +``` + +**Performance Gain**: 100 NPCs × 60 FPS × time-check = 6,000 checks/sec → 100 NPCs × 24 events/day = 2,400 events/day (negligible) + + +## IMPLEMENTATION PATTERN #7: Hybrid Real-Fake Transition + +**Problem**: NPC transitions from background (fake) to foreground (real) are jarring. + +**Solution**: Smooth transition with state interpolation. + +### Hybrid NPC System + +```csharp +class HybridNPC : MonoBehaviour +{ + public enum SimulationMode { Fake, Transitioning, Real } + + private SimulationMode currentMode = SimulationMode.Fake; + private float transitionProgress = 0f; + + // Fake state (minimal) + private Activity scheduledActivity; + + // Real state (detailed) + private NPCNeeds needs; + private AIBehavior ai; + + void Update() + { + // Determine target mode based on scrutiny + SimulationMode targetMode = DetermineTargetMode(); + + // Handle transitions + if (targetMode != currentMode) + { + if (targetMode == SimulationMode.Real && currentMode == SimulationMode.Fake) + { + StartTransitionToReal(); + } + else if (targetMode == SimulationMode.Fake && currentMode == SimulationMode.Real) + { + StartTransitionToFake(); + } + } + + // Update based on current mode + switch (currentMode) + { + case SimulationMode.Fake: + UpdateFake(); + break; + case SimulationMode.Transitioning: + UpdateTransition(); + break; + case SimulationMode.Real: + UpdateReal(); + break; + } + } + + SimulationMode DetermineTargetMode() + { + float distance = Vector3.Distance(transform.position, Camera.main.transform.position); + + if (distance < 30f || isImportant) + return SimulationMode.Real; + else + return SimulationMode.Fake; + } + + void StartTransitionToReal() + { + currentMode = SimulationMode.Transitioning; + transitionProgress = 0f; + + // Initialize real state from fake state + needs = new NPCNeeds(); + needs.hunger = NPCStatePredictor.PredictHunger(this, GameTime.Hour); + needs.energy = NPCStatePredictor.PredictEnergy(this, GameTime.Hour); + + ai = new AIBehavior(); + ai.currentActivity = scheduledActivity; + } + + void UpdateTransition() + { + transitionProgress += Time.deltaTime * 2f; // 0.5 second transition + + if (transitionProgress >= 1f) + { + currentMode = SimulationMode.Real; + } + + // Blend between fake and real + UpdateFake(); + UpdateReal(); + } + + void StartTransitionToFake() + { + currentMode = SimulationMode.Transitioning; + transitionProgress = 0f; + + // Cache important state + scheduledActivity = ai.currentActivity; + } + + void UpdateFake() + { + // Simple schedule-based behavior + scheduledActivity = NPCStatePredictor.PredictActivity(this, GameTime.Hour); + + // Follow scripted path + FollowScheduledPath(); + } + + void UpdateReal() + { + // Full simulation + needs.Update(); + ai.Update(needs); + + // Pathfinding, social, etc. + } +} +``` + +**Result**: Smooth fade between fake and real simulation, no jarring pops. + + +## COMMON PITFALL #1: Over-Simulating Background Elements + +### The Mistake + +Simulating systems at full detail even when player can't observe them. + +**Example**: +```csharp +void Update() +{ + foreach (var npc in allNPCs) // ALL 1000 NPCs + { + npc.UpdateNeeds(); // Full simulation + npc.UpdateAI(); + npc.UpdatePhysics(); + } +} +// Cost: 1000 NPCs × 0.1ms = 100ms (6× frame budget) +``` + +### Why It Happens + +- **Perfectionism**: "It should be realistic!" +- **Lack of profiling**: Didn't measure cost +- **No scrutiny awareness**: Treated all NPCs equally + +### The Fix + +**LOD System**: +```csharp +void Update() +{ + // Only simulate visible/important NPCs + foreach (var npc in visibleNPCs) // Only 50 visible + { + if (npc.isImportant) + npc.UpdateFullSimulation(); + else + npc.UpdateSimplifiedSimulation(); + } + + // Off-screen NPCs: bulk update + PopulationManager.UpdateOffScreenNPCs(); +} +// Cost: 10 important × 0.1ms + 40 visible × 0.01ms + 0.5ms bulk = 2.4ms ✅ +``` + +### Prevention + +✅ **Always** classify entities by scrutiny level +✅ **Always** profile early +✅ **Always** use LOD for simulation, not just rendering + + +## COMMON PITFALL #2: Under-Simulating Critical Systems + +### The Mistake + +Faking systems that player CAN notice or that affect gameplay. + +**Example**: +```csharp +// Enemy health is rounded to nearest 10% +void TakeDamage(float damage) +{ + health = Mathf.Round((health - damage) / 10f) * 10f; +} + +// Player deals 35 damage, sees enemy lose 40 health (off by 5!) +// Breaks game feel +``` + +### Why It Happens + +- **Over-optimization**: "Let's round for performance!" +- **Misunderstanding scrutiny**: Assumed player wouldn't notice +- **No playtesting**: Didn't verify impact + +### The Fix + +**Don't fake gameplay-critical systems**: +```csharp +void TakeDamage(float damage) +{ + health -= damage; // Precise, no rounding +} +// Cost: Negligible, but preserves game feel +``` + +### Prevention + +✅ **Never** fake systems that affect win/lose +✅ **Never** fake systems player directly interacts with +✅ **Always** playtest optimizations + + +## COMMON PITFALL #3: Jarring Transitions + +### The Mistake + +Instant transitions between fake and real states. + +**Example**: +```csharp +// Background NPC: frozen state +npc.hunger = 50f; // Static + +// Player clicks to inspect +void OnInspect() +{ + npc.StartSimulation(); // Suddenly hunger changes! + // Player sees: 50 → 47 → 44 → 41... (obvious!) +} +``` + +### Why It Happens + +- **No transition plan**: Didn't consider upgrade path +- **Binary thinking**: Fake OR real, no in-between + +### The Fix + +**Generate plausible state on transition**: +```csharp +void OnInspect() +{ + // Generate state that matches current time/activity + npc.hunger = PredictHungerFromTimeOfDay(); + npc.energy = PredictEnergyFromTimeOfDay(); + + // Start simulation from predicted state + npc.StartSimulation(); +} +// Player sees consistent state +``` + +### Prevention + +✅ **Always** plan transition from fake → real +✅ **Always** generate plausible state on upgrade +✅ **Test** by rapidly switching between states + + +## COMMON PITFALL #4: No Performance Budgeting + +### The Mistake + +Implementing systems without measuring cost or setting limits. + +**Example**: +```csharp +// Implemented social system without profiling +void Update() +{ + foreach (var npc in allNPCs) + { + Collider[] nearby = Physics.OverlapSphere(npc.position, 10f); + // ... process nearby NPCs + } +} +// LATER: Discovers this takes 50ms (3× frame budget) +``` + +### Why It Happens + +- **Premature implementation**: Coded before designing +- **No profiling**: "It should be fine..." +- **No budget**: Didn't allocate time budget upfront + +### The Fix + +**Budget first, implement within constraints**: +``` +Budget: 2ms for social system + +Reality check: + 100 NPCs × Physics.OverlapSphere (0.5ms each) = 50ms ❌ + +New design: + • Time-slice: 10 queries per frame = 5ms + • Cache results: Refresh every 5 seconds = 1ms amortized + • Spatial grid: Pre-partition space = 0.1ms ✅ + +Implement cached spatial grid approach. +``` + +### Prevention + +✅ **Always** set performance budget before implementing +✅ **Always** profile prototype before building full system +✅ **Always** measure, don't guess + + +## COMMON PITFALL #5: Synchronous Behavior + +### The Mistake + +All entities do the same thing at the same time. + +**Example**: +```csharp +// All NPCs go to work at exactly 8:00am +if (GameTime.Hour == 8) +{ + GoToWork(); +} + +// Result: 100 NPCs path simultaneously → 200ms spike +// Visual: Everyone leaves home at exact same time (robotic) +``` + +### Why It Happens + +- **Simple logic**: Exact schedules are easy to code +- **No randomization**: Forgot to add variance + +### The Fix + +**Add variance and staggering**: +```csharp +// Each NPC has slightly different schedule +void Start() +{ + workStartTime = 8f + Random.Range(-0.5f, 0.5f); // 7:30-8:30am +} + +void Update() +{ + if (GameTime.Hour >= workStartTime && !hasGoneToWork) + { + GoToWork(); + hasGoneToWork = true; + } +} + +// Performance: Spread 100 path requests over 1 hour = smooth +// Visual: NPCs leave gradually = realistic +``` + +### Prevention + +✅ **Always** add variance to schedules +✅ **Always** stagger expensive operations +✅ **Test** with many entities to spot patterns + + +## COMMON PITFALL #6: Binary All-or-Nothing Thinking + +### The Mistake + +Assuming simulation is binary: full detail OR nothing. + +**Example**: +```csharp +// Thought process: +// "We need NPC hunger system, so we'll simulate all 100 NPCs" +// OR +// "We can't afford hunger system, so we'll remove it entirely" + +// Missing: Hybrid options! +``` + +### Why It Happens + +- **Lack of framework**: Doesn't know hybrid approaches exist +- **Inexperience**: Haven't seen LOD systems + +### The Fix + +**Use hybrid spectrum**: +``` +Option 1 (FULL): Simulate all 100 NPCs, all needs, every frame + Cost: 100ms ❌ + +Option 2 (HYBRID-A): Simulate 10 important, fake 90 background + Cost: 5ms ✅ + +Option 3 (HYBRID-B): Tick-based updates, all NPCs + Cost: 2ms ✅ + +Option 4 (MINIMAL): Remove hunger, use happiness only + Cost: 0.5ms ✅ + +Choose based on gameplay need and budget. +``` + +### Prevention + +✅ **Always** consider spectrum of options +✅ **Always** use LOD, not binary +✅ **Study** existing games (they use hybrids) + + +## COMMON PITFALL #7: Ignoring Development Time + +### The Mistake + +Proposing complex solutions without considering implementation time. + +**Example**: +``` +"Implement full ecosystem with: + • Predator-prey relationships + • Food web dynamics + • Seasonal migration + • Population genetics + • Disease spread" + +Reality: This would take 6 months. Deadline is 2 weeks. +``` + +### Why It Happens + +- **Enthusiasm**: Excited about cool systems +- **No project management**: Doesn't consider timeline + +### The Fix + +**Pragmatic scoping**: +``` +Week 1: Simple predator-prey (just rabbits and foxes) +Week 2: Polish and balance + +Post-launch (if time): + • Add more species + • Add migration + • Add genetics +``` + +### Prevention + +✅ **Always** consider development time in proposals +✅ **Always** start with MVP (minimum viable product) +✅ **Always** scope to timeline, not dreams + + +## REAL-WORLD EXAMPLE #1: Hitman Crowds + +**Game**: Hitman (2016-2021) + +**Challenge**: Hundreds of NPCs in crowded locations (Paris fashion show, Mumbai streets). + +**Solution**: Multi-tier LOD system + +### Implementation + +**Tier 1: Hero NPCs** (~20) +- Full AI (behavior trees) +- Detailed animation +- Can be disguised as +- React to player actions +- Cost: High + +**Tier 2: Featured NPCs** (~50) +- Simplified AI (state machines) +- Standard animation +- Can be interacted with +- React to nearby events +- Cost: Medium + +**Tier 3: Background Crowd** (~200) +- No AI (scripted paths) +- LOD animation +- Can't interact +- Don't react +- Cost: Low + +**Tier 4: Distant Crowd** (~500+) +- Particle system or imposters +- No individual entities +- Cost: Negligible + +### Key Techniques + +1. **Disguise targets are Hero NPCs**: Player can inspect → full simulation +2. **Nearby NPCs upgrade on approach**: Tier 3 → Tier 2 when player gets close +3. **Crowd Flow**: Background NPCs follow spline paths, no pathfinding +4. **Reactions**: Only nearby NPCs react to player actions (gunshots, bodies) + +### Result + +- Feels like 1000+ people +- Actually simulates ~70 in detail +- 60 FPS on console + +**Lesson**: Player never knows background is faked because focus is on hero NPCs. + + +## REAL-WORLD EXAMPLE #2: GTA V Traffic + +**Game**: Grand Theft Auto V + +**Challenge**: Massive city with constant traffic, 60+ vehicles visible. + +**Solution**: Hybrid real-fake traffic system + +### Implementation + +**Near Player** (0-50m): Real vehicles +- Full physics (collisions, suspension) +- Detailed AI (lane changes, turns, reactions) +- High-poly models + +**Medium Distance** (50-150m): Simplified vehicles +- Simplified physics (kinematic) +- Scripted behavior (follow spline) +- Medium-poly models + +**Far Distance** (150-300m): Fake vehicles +- No physics (transform only) +- No AI (just move along road) +- Low-poly models or imposters + +**Off-Screen**: No vehicles +- Vehicles despawn when out of view +- New vehicles spawn ahead of player + +### Key Techniques + +1. **Spawning**: Vehicles spawn just beyond player's view, despawn when far behind +2. **Transition**: Vehicle smoothly upgrades from fake → simplified → real as player approaches +3. **Parked Cars**: Static props (not vehicles) until player gets very close +4. **Highway Traffic**: Uses particle system at far distances (just moving dots) + +### Result + +- City feels alive with traffic +- Actually simulates ~30 vehicles in detail +- Scales from 0 (empty road) to 60+ (highway) dynamically + +**Lesson**: Traffic LOD based on distance. Player never notices because transitions are smooth. + + +## REAL-WORLD EXAMPLE #3: Red Dead Redemption 2 Ecosystem + +**Game**: Red Dead Redemption 2 + +**Challenge**: Living ecosystem with animals, hunting, predator-prey. + +**Solution**: Hybrid simulation-statistical system + +### Implementation + +**Near Player** (0-100m): Full simulation +- Individual animals with AI +- Predator-prey behaviors +- Hunting mechanics +- Can be killed/skinned + +**Medium Distance** (100-300m): Simplified simulation +- Reduced update rate +- Simplified behaviors +- Can still be shot (for sniping) + +**Far Distance** (300m+): Statistical +- No individual animals +- Population density map +- Spawn animals when player enters region + +**Off-Screen**: Statistical model +- Track population levels +- Simulate hunting pressure +- Repopulate over time + +### Key Techniques + +1. **Population Density**: Each region has animal density (high/medium/low) +2. **Overhunting**: If player kills too many deer, density decreases +3. **Recovery**: Population recovers over in-game days +4. **Spawning**: Animals spawn just outside player's view, matching density +5. **Migration**: Statistical model moves populations between regions + +### Result + +- Ecosystem feels dynamic and responsive +- Overhunting has consequences +- Performance is manageable + +**Lesson**: Combine local simulation (what player sees) with global statistics (what player doesn't see). + + +## REAL-WORLD EXAMPLE #4: The Sims 4 Needs System + +**Game**: The Sims 4 + +**Challenge**: Needs system (hunger, bladder, energy, social, fun, hygiene) for all Sims. + +**Solution**: LOD based on player control and visibility + +### Implementation + +**Active Household** (1-8 Sims): Full simulation +- All needs simulated every tick +- Full AI for autonomy +- Detailed animations + +**Same Lot** (up to 20 Sims): Simplified simulation +- Needs updated less frequently +- Simplified AI +- Standard animations + +**Off-Lot** (neighborhood Sims): Minimal simulation +- Needs update very slowly +- No AI (time-advances their schedule) +- No rendering + +**World Population**: Statistical +- "Story progression" (birth, death, aging) +- No individual needs simulation +- State advances on schedule + +### Key Techniques + +1. **Lot-Based LOD**: Simulation detail tied to physical location +2. **Schedule Advancement**: Off-lot Sims teleport through their schedule +3. **Needs Freezing**: Off-lot Sims' needs decay very slowly +4. **Pre-computed States**: When Sim loads onto lot, needs are predicted from time + +### Result + +- Active household feels fully simulated +- Neighborhood feels alive +- Performance scales from 1 to 1000+ Sims + +**Lesson**: Use physical space (lots, zones) to define simulation boundaries. + + +## REAL-WORLD EXAMPLE #5: Cities: Skylines Traffic + +**Game**: Cities: Skylines + +**Challenge**: Simulate traffic for city of 100,000+ population. + +**Solution**: Individual agents with aggressive culling and simplification + +### Implementation + +**On-Screen Vehicles**: Full simulation +- Pathfinding (A*) +- Lane changes +- Traffic rules +- Collisions + +**Off-Screen Vehicles**: Simplified +- Pathfinding only (no rendering) +- No lane changes +- No collisions + +**Long Trips**: Teleportation +- Vehicles on long trips (>5 minutes) teleport partway +- Only simulated at start/end of trip + +**Citizen Agents**: Fake +- Citizens (people) choose destinations +- Cars are spawned to represent them +- Cars despawn when destination reached + +### Key Techniques + +1. **Agent Pool**: Reuse vehicle entities (object pooling) +2. **Pathfinding Budget**: Only N paths computed per frame +3. **Simulation Speed**: Can be slowed/paused to reduce load +4. **Highway Optimization**: Highway traffic uses faster pathfinding + +### Result + +- Can simulate 50,000+ vehicles +- Traffic jams are realistic +- Performance degrades gracefully (slowdown, not crash) + +**Lesson**: Even in simulation-heavy games, aggressive culling is essential. + + +## CROSS-REFERENCE: Related Skills + +### Within simulation-tactics Skillpack + +1. **crowd-simulation**: Focuses on crowds specifically (this skill is broader) +2. **ai-and-agent-simulation**: AI behavior details (this skill covers when to use AI vs fake) +3. **physics-simulation-patterns**: Physics-specific (this skill covers all simulation types) +4. **economic-simulation-patterns**: Economics (this skill teaches decision framework) +5. **ecosystem-simulation**: Ecosystem-specific (this skill teaches LOD approach) +6. **traffic-and-pathfinding**: Traffic-specific (this skill teaches when to path vs fake) +7. **weather-and-time**: Environmental (this skill teaches perf budgeting) + +**Use simulation-vs-faking FIRST**, then dive into specific skill for implementation details. + +### From Other Skillpacks + +- **performance-optimization**: General optimization (this skill focuses on simulation) +- **lod-systems**: Visual LOD (this skill is LOD for simulation) +- **procedural-generation**: Content generation (complements lazy state generation here) + + +## TESTING CHECKLIST + +Before shipping any simulation system, verify: + +### Performance Validation + +- ☐ **Profiled** actual frame time for simulation +- ☐ **Budget met**: Stays within allocated time budget +- ☐ **Worst case tested**: Maximum entity count, worst scenario +- ☐ **Fallback tested**: System degrades gracefully under load +- ☐ **Platform tested**: Tested on minimum spec hardware + +### Scrutiny Validation + +- ☐ **LOD working**: Entities use correct detail level based on distance/visibility +- ☐ **Transitions smooth**: No jarring pops when upgrading/downgrading +- ☐ **Background indistinguishable**: Player can't tell background is faked +- ☐ **Foreground detailed**: Player-inspected entities have appropriate detail + +### Gameplay Validation + +- ☐ **Gameplay systems simulated**: Critical systems are NOT faked +- ☐ **Cosmetic systems optimized**: Non-gameplay systems are faked appropriately +- ☐ **Player actions work**: Interactions with entities work as expected +- ☐ **Consistency maintained**: Fake entities match real entities when inspected + +### Immersion Validation + +- ☐ **Playtested**: Real players couldn't spot fakes +- ☐ **No patterns**: No obvious synchronization or repetition +- ☐ **Feels alive**: World feels dynamic and believable +- ☐ **No glitches**: Transitions don't cause visual bugs + +### Development Validation + +- ☐ **Timeline met**: Implementation finished on schedule +- ☐ **Maintainable**: Code is clean and documented +- ☐ **Extensible**: Easy to add more entities/features +- ☐ **Debuggable**: Tools exist to visualize simulation state + + +## SUMMARY: The Decision Framework + +### Step-by-Step Process + +**1. Classify by Scrutiny** + - How closely will player observe this? + - Extreme / High / Medium / Low / Minimal + +**2. Classify by Gameplay Relevance** + - Does this affect player decisions or outcomes? + - Critical / Significant / Cosmetic-Observable / Cosmetic-Unobservable + +**3. Assign Simulation Strategy** + ``` + IF scrutiny >= High AND relevance >= Significant: + → SIMULATE (full or hybrid) + + ELSE IF scrutiny >= Medium AND relevance >= Cosmetic-Observable: + → HYBRID (key features real, details faked) + + ELSE IF scrutiny >= Low: + → FAKE (convincing appearance, no simulation) + + ELSE: + → REMOVE or STATISTICAL (bulk operations) + ``` + +**4. Allocate Performance Budget** + - Measure baseline cost + - Set budget based on frame time + - Design within constraints + +**5. Implement with LOD** + - Multiple detail levels + - Smooth transitions + - Distance/visibility-based + +**6. Validate** + - Profile + - Playtest + - Iterate + +### The Golden Rule + +> **Simulate what the player OBSERVES and what affects GAMEPLAY. Fake everything else.** + +This is the foundational skill. Master this, and all other simulation decisions become clear. + + +## END OF SKILL + +This skill should be used at the START of any simulation design. It prevents the two catastrophic failure modes: +1. Over-simulation (wasted performance) +2. Under-simulation (broken immersion) + +Apply the frameworks rigorously, and your simulations will be performant, believable, and maintainable. diff --git a/skills/using-simulation-tactics/traffic-and-pathfinding.md b/skills/using-simulation-tactics/traffic-and-pathfinding.md new file mode 100644 index 0000000..15c740f --- /dev/null +++ b/skills/using-simulation-tactics/traffic-and-pathfinding.md @@ -0,0 +1,1320 @@ + +# Traffic and Pathfinding + +**When to use this skill**: When implementing navigation systems, traffic simulation, crowd movement, or any scenario involving multiple agents finding paths through an environment. Critical for city builders, RTS games, open-world navigation, and crowd simulation. + +**What this skill provides**: Comprehensive understanding of pathfinding algorithms (A*, Dijkstra, JPS), hierarchical pathfinding for scale, traffic flow management, congestion handling, dynamic re-pathing, and performance optimization patterns for 1000+ simultaneous agents. + + +## Core Concepts + +### Pathfinding Algorithms + +**A* (A-Star)** +- **Use case**: Single-source, single-destination pathfinding with heuristic guidance +- **Complexity**: O(E log V) where E = edges, V = vertices +- **Strengths**: Optimal paths, widely understood, efficient with good heuristics +- **Limitations**: Explores many nodes for long distances, no path sharing between agents + +**Dijkstra's Algorithm** +- **Use case**: Single-source to all destinations, guaranteed shortest path +- **Complexity**: O(E log V) +- **Strengths**: Optimal paths, useful for pre-computing distance maps +- **Use when**: Need paths from one point to ALL other points (e.g., delivery hub) + +**Jump Point Search (JPS)** +- **Use case**: Grid-based pathfinding with symmetry breaking +- **Complexity**: Much faster than A* on uniform cost grids +- **Strengths**: 10-40× speedup on open grids, same path quality as A* +- **Limitations**: Only works on uniform-cost grids, requires preprocessing + +**Hierarchical Pathfinding (HPA*)** +- **Use case**: Long-distance navigation in large environments +- **Method**: Divide map into clusters, build high-level graph, plan at multiple levels +- **Complexity**: Drastically reduced search space (10-100× faster for long paths) +- **Strengths**: Scales to massive maps, can cache high-level paths +- **Example**: Cities Skylines uses hierarchical road network (highways → arterial → local) + +**Flow Fields** +- **Use case**: Many agents moving toward same destination (crowds, RTS unit groups) +- **Method**: Pre-compute direction field across entire map from goal +- **Complexity**: O(N) for field generation, O(1) per agent movement +- **Strengths**: Thousands of agents with negligible per-agent cost +- **Best for**: RTS games (100+ units attack-moving), crowd evacuation scenarios + +**Navigation Mesh (NavMesh)** +- **Use case**: 3D environments, non-grid spaces, complex terrain +- **Method**: Polygon mesh representing walkable surfaces, pathfind on mesh +- **Strengths**: Handles slopes, stairs, platforms; natural for 3D worlds +- **Tools**: Recast/Detour (industry standard), Unity NavMesh + +### Traffic Flow Concepts + +**Congestion Management** +- **Dynamic Cost Adjustment**: Increase edge cost based on vehicle density +- **Heat Maps**: Track traffic density per road segment, update every N frames +- **Spillback**: Model how congestion propagates backward from blockages + +**Lane-Based Navigation** +- **Multi-lane Roads**: Represent roads as bundles of parallel lanes +- **Lane Changes**: Model as higher-cost transitions between adjacent lanes +- **Turn Lanes**: Dedicated lanes for turning increase intersection throughput + +**Intersection Management** +- **Traffic Signals**: Timed signals with phase plans (red/yellow/green) +- **Reservation Systems**: Time-space reservations for autonomous vehicles +- **Priority Rules**: Right-of-way, yield signs, stop signs + + +## Decision Frameworks + +### Framework 1: Choosing the Right Pathfinding Algorithm + +``` +START: What's my navigation scenario? + +├─ GRID-BASED with UNIFORM COSTS? +│ ├─ Small map (< 1000 nodes)? → Use A* (simple, fast enough) +│ ├─ Large open grids? → Use JPS (10-40× faster than A*) +│ └─ Many obstacles? → Use A* (JPS benefits diminish) +│ +├─ NEED PATHS TO MULTIPLE DESTINATIONS? +│ └─ From ONE source to ALL destinations? → Use Dijkstra (single search) +│ +├─ LARGE MAP (> 10,000 nodes) with LONG PATHS? +│ └─ Use Hierarchical Pathfinding (HPA*, hierarchical A*) +│ - Divide into clusters (16×16 or 32×32) +│ - Build inter-cluster graph +│ - Plan at high level, refine locally +│ +├─ MANY AGENTS (> 100) moving to SAME GOAL? +│ └─ Use Flow Fields +│ - Compute once per destination +│ - All agents follow field (O(1) per agent) +│ - Update field when goal changes +│ +├─ 3D ENVIRONMENT with COMPLEX TERRAIN? +│ └─ Use NavMesh (Recast/Detour, Unity NavMesh) +│ - Handles slopes, stairs, platforms +│ - Better for non-grid spaces +│ +└─ DYNAMIC ENVIRONMENT with FREQUENT CHANGES? + └─ Use D* Lite or LPA* + - Incrementally repair paths when map changes + - Much faster than full recalculation +``` + +**Example Decision**: Cities Skylines traffic +- Large city map (100k+ nodes) → Hierarchical pathfinding +- Multiple vehicle types → NavMesh for complex vehicle physics +- Traffic congestion → Dynamic cost adjustment every 10 frames + +### Framework 2: When to Recalculate Paths + +``` +NEVER recalculate every frame (performance death) + +├─ PATH BECOMES INVALID? +│ ├─ Blocked by obstacle → Recalculate immediately +│ ├─ Destination moved → Recalculate immediately +│ └─ Road closed/destroyed → Recalculate immediately +│ +├─ PATH STILL VALID but SUBOPTIMAL? +│ ├─ Traffic congestion on route → Recalculate after delay (5-10 sec) +│ ├─ Found shortcut → Recalculate opportunistically (low priority) +│ └─ Better route available → Queue for background recalc +│ +├─ AGENT DEVIATED FROM PATH? +│ ├─ Small deviation → Use local correction (steer back) +│ ├─ Large deviation → Recalculate from current position +│ └─ Pushed off path → Recalculate after N failed corrections +│ +└─ PERIODIC REFRESH? + └─ Recalculate every N seconds (30-60 sec typical) + - Catch gradual cost changes + - Spread recalc cost over time + - Lower priority than invalid paths +``` + +**Performance Budget Example**: +- 1000 vehicles at 60 FPS +- Budget: 5ms for pathfinding per frame +- Max paths per frame: ~10 (0.5ms each) +- Queue remaining requests, process over multiple frames + +### Framework 3: Exact vs Approximate Paths + +``` +Choose path quality based on agent importance and distance + +├─ DISTANT FROM CAMERA (> 100 units)? +│ └─ Use LOW-DETAIL paths +│ - Fewer waypoints (every 10th node) +│ - Straight-line segments +│ - Skip local avoidance +│ - Update less frequently +│ +├─ NEAR CAMERA (< 50 units)? +│ └─ Use HIGH-DETAIL paths +│ - All waypoints +│ - Smooth curves (spline interpolation) +│ - Local steering behaviors +│ - Frequent updates +│ +├─ BACKGROUND TRAFFIC? +│ └─ Use SIMPLIFIED paths +│ - Scripted routes (no pathfinding) +│ - Pre-baked traffic patterns +│ - No collision avoidance +│ +└─ PLAYER-CONTROLLED or HERO UNITS? + └─ Use EXACT paths + - Full pathfinding + - Smooth movement + - Perfect collision avoidance +``` + +**Level-of-Detail System**: +``` +Distance from camera: +- 0-50 units: Full pathfinding, 10 Hz updates +- 50-100 units: Reduced waypoints, 5 Hz updates +- 100-200 units: Major waypoints only, 1 Hz updates +- 200+ units: Straight-line movement, 0.2 Hz updates +``` + + +## Implementation Patterns + +### Pattern 1: Path Caching and Sharing + +**Problem**: Recalculating identical paths wastes CPU. Multiple agents going A→B all compute separately. + +**Solution**: Cache paths in lookup table, share between agents. + +```python +class PathCache: + def __init__(self, max_size=10000, ttl=60.0): + self.cache = {} # (start, goal) -> CachedPath + self.max_size = max_size + self.ttl = ttl # Time-to-live in seconds + + def get_path(self, start, goal, current_time): + key = (start, goal) + + if key in self.cache: + cached = self.cache[key] + # Check if still valid + if current_time - cached.timestamp < self.ttl: + cached.ref_count += 1 + return cached.path + else: + del self.cache[key] # Expired + + return None + + def cache_path(self, start, goal, path, current_time): + key = (start, goal) + + # Evict oldest if cache full + if len(self.cache) >= self.max_size: + oldest = min(self.cache.items(), key=lambda x: x[1].timestamp) + del self.cache[oldest[0]] + + self.cache[key] = CachedPath(path, current_time, ref_count=1) + + def invalidate_region(self, bbox): + """Invalidate cached paths through region (for dynamic obstacles)""" + keys_to_remove = [] + for key, cached in self.cache.items(): + if self._path_intersects_bbox(cached.path, bbox): + keys_to_remove.append(key) + + for key in keys_to_remove: + del self.cache[key] + +class CachedPath: + def __init__(self, path, timestamp, ref_count=0): + self.path = path + self.timestamp = timestamp + self.ref_count = ref_count +``` + +**Benefits**: +- 10-100× speedup when multiple agents share destinations +- Reduced CPU for common routes (residential → downtown) +- Automatic invalidation when map changes + +**When to use**: +- City builders (many cars going to popular districts) +- RTS games (multiple units attack-moving to same location) +- Crowd simulations (people going to exits, landmarks) + +**Real-world example**: SimCity 4 caches paths between residential and commercial zones, updated when zoning changes. + +### Pattern 2: Hierarchical A* for Large Maps + +**Problem**: A* searches too many nodes on large maps. 100×100 grid = 10,000 nodes for cross-map paths. + +**Solution**: Divide map into clusters, create high-level graph, search at multiple abstraction levels. + +```python +class HierarchicalPathfinder: + def __init__(self, world_map, cluster_size=16): + self.world_map = world_map + self.cluster_size = cluster_size + + # Build hierarchy + self.clusters = self._build_clusters() + self.high_level_graph = self._build_high_level_graph() + + def find_path(self, start, goal): + # Step 1: Find which clusters contain start and goal + start_cluster = self._get_cluster(start) + goal_cluster = self._get_cluster(goal) + + if start_cluster == goal_cluster: + # Same cluster, use standard A* + return self._astar_local(start, goal) + + # Step 2: Find high-level path (cluster to cluster) + cluster_path = self._astar_high_level(start_cluster, goal_cluster) + + if not cluster_path: + return None # No path exists + + # Step 3: Refine to low-level path (actual nodes) + full_path = [] + + # Path from start to first cluster exit + entry_point = self._get_entry_point(start_cluster, cluster_path[1]) + full_path.extend(self._astar_local(start, entry_point)) + + # Path through intermediate clusters + for i in range(1, len(cluster_path) - 1): + current_cluster = cluster_path[i] + next_cluster = cluster_path[i + 1] + + exit_point = self._get_entry_point(current_cluster, next_cluster) + full_path.extend(self._astar_local(entry_point, exit_point)) + entry_point = exit_point + + # Path from last cluster entry to goal + full_path.extend(self._astar_local(entry_point, goal)) + + return full_path + + def _build_clusters(self): + """Divide map into grid of clusters""" + clusters = [] + for y in range(0, self.world_map.height, self.cluster_size): + for x in range(0, self.world_map.width, self.cluster_size): + cluster = Cluster(x, y, self.cluster_size) + self._find_border_nodes(cluster) + clusters.append(cluster) + return clusters + + def _build_high_level_graph(self): + """Build graph connecting cluster border nodes""" + graph = {} + + for cluster in self.clusters: + for border_node in cluster.border_nodes: + # Pre-compute paths to all other border nodes in same cluster + for other_node in cluster.border_nodes: + if border_node != other_node: + path = self._astar_local(border_node, other_node) + if path: + graph[(border_node, other_node)] = len(path) + + return graph + + def _astar_high_level(self, start_cluster, goal_cluster): + """A* search on cluster graph""" + # Standard A* but on clusters, not individual nodes + # Returns list of clusters forming high-level path + pass + + def _astar_local(self, start, goal): + """Standard A* within a cluster or between nearby points""" + pass +``` + +**Performance Improvement**: +- **Without hierarchy**: Search 10,000 nodes for 100×100 map +- **With hierarchy** (16×16 clusters): Search ~40 clusters + local refinement = ~500 nodes +- **Speedup**: 20× faster for long-distance paths + +**When to use**: +- Maps larger than 5000 nodes +- Agents frequently traveling long distances +- Multiple levels of road hierarchy (highways vs local) + +**Real-world example**: Cities Skylines uses 3-level hierarchy: +1. Highway network (high-level) +2. Arterial roads (mid-level) +3. Local streets (low-level) + +### Pattern 3: Flow Fields for Crowds + +**Problem**: 100+ units moving to same goal. Each running A* = 100× redundant computation. + +**Solution**: Compute direction field once, all units follow arrows. + +```python +import numpy as np +from collections import deque + +class FlowField: + def __init__(self, world_map): + self.world_map = world_map + self.width = world_map.width + self.height = world_map.height + + # Pre-allocate arrays + self.cost_field = np.full((self.height, self.width), np.inf) + self.integration_field = np.full((self.height, self.width), np.inf) + self.flow_field = np.zeros((self.height, self.width, 2)) # Direction vectors + + def generate(self, goal_position): + """Generate flow field from goal""" + # Step 1: Create cost field (cost to traverse each cell) + self._generate_cost_field() + + # Step 2: Integration field (distance from goal via Dijkstra) + self._generate_integration_field(goal_position) + + # Step 3: Flow field (direction of steepest descent) + self._generate_flow_field() + + def _generate_cost_field(self): + """Assign traversal cost to each cell""" + for y in range(self.height): + for x in range(self.width): + if self.world_map.is_walkable(x, y): + # Base cost + terrain cost + self.cost_field[y, x] = self.world_map.get_terrain_cost(x, y) + else: + self.cost_field[y, x] = np.inf # Unwalkable + + def _generate_integration_field(self, goal): + """Dijkstra from goal, fills integration field with distances""" + gx, gy = goal + self.integration_field.fill(np.inf) + self.integration_field[gy, gx] = 0 + + # BFS/Dijkstra from goal + queue = deque([goal]) + + while queue: + x, y = queue.popleft() + current_cost = self.integration_field[y, x] + + # Check all neighbors + for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0), + (1, 1), (1, -1), (-1, 1), (-1, -1)]: + nx, ny = x + dx, y + dy + + if not (0 <= nx < self.width and 0 <= ny < self.height): + continue + + if self.cost_field[ny, nx] == np.inf: + continue # Unwalkable + + # Calculate cost to reach neighbor + new_cost = current_cost + self.cost_field[ny, nx] + + if new_cost < self.integration_field[ny, nx]: + self.integration_field[ny, nx] = new_cost + queue.append((nx, ny)) + + def _generate_flow_field(self): + """Generate direction vectors pointing toward goal""" + for y in range(self.height): + for x in range(self.width): + if self.cost_field[y, x] == np.inf: + continue # Unwalkable + + # Find neighbor with lowest integration value + best_dir = None + best_cost = self.integration_field[y, x] + + for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0), + (1, 1), (1, -1), (-1, 1), (-1, -1)]: + nx, ny = x + dx, y + dy + + if not (0 <= nx < self.width and 0 <= ny < self.height): + continue + + neighbor_cost = self.integration_field[ny, nx] + if neighbor_cost < best_cost: + best_cost = neighbor_cost + best_dir = (dx, dy) + + if best_dir: + # Normalize direction + length = np.sqrt(best_dir[0]**2 + best_dir[1]**2) + self.flow_field[y, x] = (best_dir[0] / length, + best_dir[1] / length) + + def get_direction(self, position): + """Get movement direction at position""" + x, y = int(position[0]), int(position[1]) + if 0 <= x < self.width and 0 <= y < self.height: + return self.flow_field[y, x] + return (0, 0) + + +class Agent: + def __init__(self, position, speed=1.0): + self.position = np.array(position, dtype=float) + self.speed = speed + + def update(self, flow_field, dt): + """Move following flow field""" + direction = flow_field.get_direction(self.position) + self.position += direction * self.speed * dt +``` + +**Performance Comparison**: +- **Individual A***: 100 agents × 1ms each = 100ms per frame +- **Flow field**: 5ms generation + 100 agents × 0.001ms = 5.1ms per frame +- **Speedup**: ~20× faster + +**When to use**: +- RTS games (attack-move, rally points) +- Crowd evacuation (everyone heading to exits) +- Tower defense (enemies moving to goal) +- Any scenario with 50+ agents sharing destination + +**Real-world example**: Supreme Commander uses flow fields for 1000+ unit formations moving together. + +### Pattern 4: Dynamic Cost Adjustment for Traffic + +**Problem**: All vehicles choose "optimal" path, creating traffic jam. No adaptation to congestion. + +**Solution**: Track vehicle density per road segment, increase cost of congested roads, trigger re-routing. + +```python +class TrafficManager: + def __init__(self, road_network): + self.road_network = road_network + self.traffic_density = {} # edge_id -> vehicle count + self.base_costs = {} # edge_id -> base travel time + self.update_interval = 1.0 # Update costs every N seconds + self.last_update = 0 + + # Initialize + for edge in road_network.edges: + self.traffic_density[edge.id] = 0 + self.base_costs[edge.id] = edge.length / edge.speed_limit + + def update(self, vehicles, current_time): + """Update traffic densities and edge costs""" + if current_time - self.last_update < self.update_interval: + return + + # Reset densities + for edge_id in self.traffic_density: + self.traffic_density[edge_id] = 0 + + # Count vehicles on each edge + for vehicle in vehicles: + if vehicle.current_edge: + self.traffic_density[vehicle.current_edge] += 1 + + # Update edge costs based on congestion + for edge in self.road_network.edges: + density = self.traffic_density[edge.id] + capacity = edge.lane_count * 10 # Vehicles per lane + + # Congestion factor (BPR function - standard in traffic engineering) + congestion_ratio = density / capacity + congestion_factor = 1.0 + 0.15 * (congestion_ratio ** 4) + + # Update edge cost + new_cost = self.base_costs[edge.id] * congestion_factor + edge.current_cost = new_cost + + # Mark for re-routing if severely congested + if congestion_ratio > 0.8: + self._trigger_reroute(edge) + + self.last_update = current_time + + def _trigger_reroute(self, edge): + """Notify vehicles on congested edge to consider re-routing""" + for vehicle in self.road_network.get_vehicles_on_edge(edge.id): + # Don't reroute everyone at once (causes oscillation) + if random.random() < 0.2: # 20% chance + vehicle.request_reroute(reason='congestion') + + def get_edge_cost(self, edge_id, include_congestion=True): + """Get current cost of edge""" + if include_congestion: + return self.road_network.get_edge(edge_id).current_cost + else: + return self.base_costs[edge_id] + + +class Vehicle: + def __init__(self, vehicle_id, start, destination): + self.id = vehicle_id + self.position = start + self.destination = destination + self.path = [] + self.current_edge = None + self.last_reroute_time = 0 + self.reroute_cooldown = 10.0 # Don't reroute more than every 10 sec + + def request_reroute(self, reason='congestion'): + """Request path recalculation""" + current_time = time.time() + + # Cooldown to prevent thrashing + if current_time - self.last_reroute_time < self.reroute_cooldown: + return + + # Calculate new path with current costs + new_path = self.pathfinder.find_path( + self.position, + self.destination, + use_current_costs=True + ) + + if new_path and len(new_path) < len(self.path) * 0.9: + # New path is significantly better (10% shorter) + self.path = new_path + self.last_reroute_time = current_time +``` + +**Bureau of Public Roads (BPR) Function**: +``` +travel_time = free_flow_time × (1 + α × (density/capacity)^β) + +Standard values: +- α = 0.15 +- β = 4 +- Results in realistic congestion curves +``` + +**When to use**: +- City builders (Cities Skylines, SimCity) +- Traffic simulators +- Delivery route optimization +- Any network flow problem with capacity constraints + +**Real-world example**: Cities Skylines updates road costs every 30 game ticks based on vehicle counts. Vehicles reroute probabilistically to avoid oscillation. + +### Pattern 5: Asynchronous Pathfinding with Request Queue + +**Problem**: Can't calculate all 1000 paths in single frame. Causes frame drops. + +**Solution**: Queue path requests, process limited number per frame, deliver asynchronously. + +```python +from collections import deque +from dataclasses import dataclass +from typing import Callable, Optional +import time + +@dataclass +class PathRequest: + id: int + start: tuple + goal: tuple + priority: int # Lower = higher priority + callback: Callable + timestamp: float + max_search_nodes: int = 10000 + +class AsyncPathfinder: + def __init__(self, pathfinder, max_ms_per_frame=5.0): + self.pathfinder = pathfinder + self.max_ms_per_frame = max_ms_per_frame + self.request_queue = [] # Priority queue + self.next_request_id = 0 + self.active_searches = {} # id -> generator + + def request_path(self, start, goal, callback, priority=5): + """Queue a path request""" + request = PathRequest( + id=self.next_request_id, + start=start, + goal=goal, + priority=priority, + callback=callback, + timestamp=time.time() + ) + self.next_request_id += 1 + + # Insert into priority queue (min-heap) + import heapq + heapq.heappush(self.request_queue, (priority, request)) + + return request.id + + def update(self): + """Process path requests within time budget""" + start_time = time.perf_counter() + + # Continue active searches first + completed_ids = [] + for request_id, search_gen in self.active_searches.items(): + try: + next(search_gen) # Continue search + + # Check time budget + elapsed_ms = (time.perf_counter() - start_time) * 1000 + if elapsed_ms > self.max_ms_per_frame: + return # Out of time, continue next frame + + except StopIteration as result: + # Search completed + path = result.value + request = self._get_request(request_id) + request.callback(path) + completed_ids.append(request_id) + + # Remove completed searches + for req_id in completed_ids: + del self.active_searches[req_id] + + # Start new searches if time permits + while self.request_queue: + elapsed_ms = (time.perf_counter() - start_time) * 1000 + if elapsed_ms > self.max_ms_per_frame: + break + + # Get highest priority request + _, request = heapq.heappop(self.request_queue) + + # Start incremental search + search_gen = self.pathfinder.find_path_incremental( + request.start, + request.goal, + max_nodes_per_step=100 + ) + self.active_searches[request.id] = search_gen + + def cancel_request(self, request_id): + """Cancel pending request""" + if request_id in self.active_searches: + del self.active_searches[request_id] + + +class IncrementalPathfinder: + def find_path_incremental(self, start, goal, max_nodes_per_step=100): + """A* that yields control periodically""" + open_set = [] + heapq.heappush(open_set, (0, start)) + came_from = {} + g_score = {start: 0} + nodes_processed = 0 + + while open_set: + current = heapq.heappop(open_set)[1] + + if current == goal: + # Found path + return self.reconstruct_path(came_from, current) + + for neighbor in self.get_neighbors(current): + tentative_g = g_score[current] + self.distance(current, neighbor) + + if neighbor not in g_score or tentative_g < g_score[neighbor]: + came_from[neighbor] = current + g_score[neighbor] = tentative_g + f_score = tentative_g + self.heuristic(neighbor, goal) + heapq.heappush(open_set, (f_score, neighbor)) + + nodes_processed += 1 + + # Yield control periodically + if nodes_processed >= max_nodes_per_step: + nodes_processed = 0 + yield # Return control to caller + + return None # No path found + + +# Usage +pathfinder = AsyncPathfinder(IncrementalPathfinder(world_map)) + +# In vehicle AI +def on_path_received(path): + vehicle.path = path + vehicle.state = 'following_path' + +pathfinder.request_path( + vehicle.position, + vehicle.destination, + on_path_received, + priority=vehicle.get_priority() +) + +# In game loop +def update(): + pathfinder.update() # Processes requests within time budget + # ... rest of game logic +``` + +**Performance Budget Example**: +- 60 FPS = 16.67ms per frame +- Pathfinding budget: 5ms (30% of frame) +- Average path: 0.5ms +- Paths per frame: ~10 +- 1000 vehicles = 100 frames to recalc all (1.67 seconds) + +**Priority System**: +``` +Priority 1: Player-visible vehicles +Priority 2: Vehicles near camera +Priority 3: Vehicles with invalid paths +Priority 4: Periodic refresh +Priority 5: Background traffic +``` + +**When to use**: +- Any game with 100+ agents needing paths +- When maintaining 60 FPS is critical +- Open-world games with dynamic environments + +**Real-world example**: Unity's NavMesh system uses time-sliced pathfinding, spreading calculations across multiple frames. + + +## Common Pitfalls + +### Pitfall 1: Recalculating Paths Every Frame + +**Symptom**: Frame rate drops to < 5 FPS with 1000 agents. + +**Why it happens**: Developer doesn't realize cost of pathfinding. Puts `find_path()` in `update()` loop. + +**Cost analysis**: +``` +A* complexity: O(E log V) +Typical map: 10,000 nodes, 40,000 edges +Cost per path: ~1ms +1000 agents × 1ms × 60 FPS = 60,000ms = 60 seconds per frame! +Result: 0.016 FPS (one frame per minute) +``` + +**Solution**: Path caching, lazy evaluation, request queuing. + +```python +# WRONG: Recalculates every frame +class Vehicle: + def update(self): + self.path = self.pathfinder.find_path(self.pos, self.dest) # TERRIBLE! + self.move_along_path() + +# RIGHT: Cache path, only recalculate when needed +class Vehicle: + def update(self): + if self._path_needs_recalc(): + self.request_path_recalc() # Queued, async + self.move_along_path() + + def _path_needs_recalc(self): + # Only recalc if path invalid or significantly suboptimal + if not self.path: + return True + if self._path_blocked(): + return True + if time.time() - self.last_path_time > 60.0: + return True # Periodic refresh + return False +``` + +**Red flags to watch for**: +- `find_path()` called in `update()` or game loop +- No caching or memoization +- No check for "does path need recalculation?" + +### Pitfall 2: No Fallback for Unreachable Destinations + +**Symptom**: Vehicles freeze when destination unreachable. Game state corrupted. + +**Why it happens**: Pathfinding returns `None` or empty list, vehicle code doesn't handle it. + +```python +# WRONG: No error handling +def update(self): + self.path = find_path(self.pos, self.dest) + self.move_to(self.path[0]) # IndexError if path is empty! + +# RIGHT: Fallback behavior +def update(self): + if not self.path: + self.path = find_path(self.pos, self.dest) + + if not self.path: + # No path exists, fallback behavior + self._handle_unreachable_destination() + return + + self.move_to(self.path[0]) + +def _handle_unreachable_destination(self): + # Option 1: Find nearest reachable point + self.dest = find_nearest_reachable(self.dest) + + # Option 2: Change behavior + self.state = 'idle' + + # Option 3: Remove vehicle + self.mark_for_removal() +``` + +**Fallback strategies**: +1. **Find nearest reachable point**: Pathfind to closest valid destination +2. **Wait and retry**: Obstacle might move, try again in N seconds +3. **Change goal**: Pick alternate destination +4. **Enter idle state**: Stop trying, wait for player input +5. **Despawn**: Remove vehicle from simulation (background traffic) + +### Pitfall 3: Traffic Deadlocks (No Re-routing) + +**Symptom**: Vehicles stuck in gridlock. All routes blocked by waiting vehicles. + +**Why it happens**: Vehicles commit to paths without checking future congestion. No dynamic re-routing. + +**Example scenario**: +``` +Intersection: + ↓ +← + → + ↑ + +All four roads filled with vehicles waiting to cross. +Each blocked by vehicle on adjacent road. +Classic deadlock: A waits for B, B waits for C, C waits for D, D waits for A. +``` + +**Solutions**: + +1. **Time-space reservations** (prevents deadlock before it happens): +```python +class IntersectionManager: + def __init__(self): + self.reservations = {} # (x, y, time) -> vehicle_id + + def request_crossing(self, vehicle, path_through_intersection, current_time): + # Check if time-space slots are available + for i, (x, y) in enumerate(path_through_intersection): + arrival_time = current_time + i * vehicle.time_per_cell + + if (x, y, arrival_time) in self.reservations: + return False # Conflict, deny request + + # Reserve slots + for i, (x, y) in enumerate(path_through_intersection): + arrival_time = current_time + i * vehicle.time_per_cell + self.reservations[(x, y, arrival_time)] = vehicle.id + + return True # Approved +``` + +2. **Deadlock detection and breaking**: +```python +def detect_deadlock(self, vehicles): + # Build wait-for graph + wait_graph = {} + for v in vehicles: + if v.state == 'waiting': + blocking_vehicle = self.get_blocking_vehicle(v) + if blocking_vehicle: + wait_graph[v.id] = blocking_vehicle.id + + # Find cycles (deadlocks) + cycles = self.find_cycles(wait_graph) + + # Break deadlocks: back up lowest-priority vehicle + for cycle in cycles: + lowest_priority = min(cycle, key=lambda v: vehicles[v].priority) + vehicles[lowest_priority].back_up() + vehicles[lowest_priority].request_reroute() +``` + +3. **Traffic light coordination** (prevents conflicts): +```python +class TrafficLightController: + def __init__(self, intersections): + self.lights = {} + self.phase_duration = 30.0 # seconds + self.yellow_duration = 3.0 + + # Coordinate lights to create "green waves" + for i, intersection in enumerate(intersections): + offset = i * 5.0 # Stagger by 5 seconds + self.lights[intersection.id] = TrafficLight(offset) +``` + +**Red flags**: +- No intersection management system +- Vehicles can occupy same cell +- No wait timeout (reroute after stuck for N seconds) +- No deadlock detection + +### Pitfall 4: Ignoring Congestion (All Choose Same "Optimal" Path) + +**Symptom**: One route heavily congested while alternate routes empty. Vehicles don't adapt. + +**Why it happens**: Pathfinding uses static costs. All vehicles calculate same "shortest" path. + +**Example**: Highway with traffic jam, but parallel road empty. +``` +Start =====[CONGESTED HIGHWAY (1000 vehicles)]===== Dest + \ / + [EMPTY ALTERNATE ROUTE (0 vehicles)] + +All vehicles still route to highway (shorter distance, but longer time) +``` + +**Solution**: Dynamic cost based on current traffic. + +```python +def get_edge_cost(self, edge): + base_cost = edge.length / edge.speed_limit + + # Count vehicles on edge + vehicle_count = len(edge.vehicles) + capacity = edge.lanes * 10 + + # BPR congestion function + congestion_ratio = vehicle_count / capacity + congestion_factor = 1.0 + 0.15 * (congestion_ratio ** 4) + + return base_cost * congestion_factor + +# Also: Stochastic routing (add randomness to prevent everyone choosing same path) +def choose_path(self, paths): + # Instead of always picking shortest, add some randomness + costs = [self.get_path_cost(p) for p in paths] + + # Softmax selection (prefer shorter, but not exclusively) + probs = self.softmax([-c for c in costs], temperature=2.0) + return np.random.choice(paths, p=probs) +``` + +**Feedback loop prevention**: +- Don't reroute all vehicles simultaneously (causes oscillation) +- Add hysteresis: only reroute if new path 10-20% better +- Staggered updates: different vehicles update at different times + +### Pitfall 5: No Hierarchical Pathfinding (Poor Scaling) + +**Symptom**: Works fine with 100 vehicles on small map. Becomes slideshow with 1000 vehicles on large map. + +**Why it happens**: A* search space grows quadratically with map size. No hierarchical structure. + +**Complexity comparison**: +``` +Small map: 50×50 = 2,500 nodes +- A* average search: ~500 nodes +- 100 vehicles: 50,000 node expansions per frame +- Performance: 60 FPS ✓ + +Large map: 200×200 = 40,000 nodes +- A* average search: ~10,000 nodes (not 500!) +- 1000 vehicles: 10,000,000 node expansions per frame +- Performance: 0.5 FPS ✗ +``` + +**Why it's quadratic**: Doubling map size quadruples nodes AND doubles average path length. + +**Solution**: Hierarchical pathfinding. + +```python +# Cities Skylines approach: 3-level hierarchy +class HierarchicalRoadNetwork: + def __init__(self): + self.highways = Graph() # Level 3: ~100 nodes + self.arterials = Graph() # Level 2: ~1,000 nodes + self.local_roads = Graph() # Level 1: ~40,000 nodes + + def find_path(self, start, goal): + # 1. Find nearest highway on-ramps + start_ramp = self.local_roads.find_nearest_highway_access(start) + goal_ramp = self.local_roads.find_nearest_highway_access(goal) + + # 2. Route on highway network (fast, only ~100 nodes) + highway_path = self.highways.find_path(start_ramp, goal_ramp) + + # 3. Local routing to/from ramps + path = [] + path += self.local_roads.find_path(start, start_ramp) + path += highway_path + path += self.local_roads.find_path(goal_ramp, goal) + + return path +``` + +**When to implement hierarchy**: +- Map > 5,000 nodes +- Agents travel long distances (> 50% of map) +- Natural hierarchy exists (highways, arterials, local streets) + +**Pitfall**: Not all games need this! Small RTS maps (50×50) don't benefit from hierarchy overhead. + + +## Real-World Examples + +### Example 1: Cities Skylines Traffic System + +**Scale**: 100,000+ vehicles, 200×200 tile cities, real-time simulation. + +**Approach**: +1. **Hierarchical road network**: + - Highways (high-level, ~200 nodes) + - Arterial roads (mid-level, ~2,000 nodes) + - Local streets (low-level, ~20,000 nodes) + +2. **Path caching**: + - Common routes cached (residential → commercial, industrial → highway) + - Cache invalidated when zoning changes or roads built/destroyed + +3. **Dynamic congestion**: + - Updates road costs every 30 ticks based on vehicle density + - BPR function for realistic congestion curves + - Vehicles reroute probabilistically (20% check each update) + +4. **LOD system**: + - Vehicles far from camera use simplified pathfinding + - Close vehicles get full path with lane changes + - Distant vehicles: straight-line movement between districts + +5. **Time-sliced pathfinding**: + - Budget: 10ms per frame for pathfinding + - ~50 path calculations per frame + - Priority queue: player-visible > near camera > background + +**Performance**: Maintains 30-60 FPS with 10,000+ active vehicles. + +**Key lesson**: Hierarchy + caching + LOD is essential for large-scale traffic. + +### Example 2: Unity NavMesh (Recast/Detour) + +**Use case**: 3D games with complex terrain (slopes, stairs, platforms). + +**Approach**: +1. **Voxelization**: Convert 3D geometry to voxel grid +2. **Heightfield**: Identify walkable surfaces +3. **Contour extraction**: Find boundaries of walkable areas +4. **Polygon mesh**: Generate simplified navigation mesh +5. **A* on mesh**: Pathfind on polygons (not voxels) + +**Benefits**: +- Handles 3D terrain naturally (no grid needed) +- Efficient: Only walkable areas in graph +- Industry standard (Unreal, Unity, CryEngine) + +**Example**: +```csharp +// Unity NavMesh API +NavMeshPath path = new NavMeshPath(); +NavMesh.CalculatePath(startPos, endPos, NavMesh.AllAreas, path); + +if (path.status == NavMeshPathStatus.PathComplete) { + agent.SetPath(path); +} else { + // Fallback: partial path or alternate destination + agent.SetDestination(FindNearestValidPoint(endPos)); +} +``` + +**Key lesson**: For 3D games, NavMesh is almost always better than grid-based pathfinding. + +### Example 3: Supreme Commander - Flow Fields for Massive Unit Counts + +**Scale**: 1,000+ units moving together in RTS formations. + +**Problem**: Individual A* per unit doesn't scale. 1000 units × 1ms = 1 second per frame. + +**Solution**: Flow fields. + +``` +1. Player orders 1000 units to attack enemy base +2. Generate flow field from enemy base (5ms one-time cost) +3. Each unit follows flow field (0.001ms per unit) +4. Total: 5ms + 1ms = 6ms (instead of 1000ms) +``` + +**Implementation details**: +- Flow field generated on high-level grid (16×16 cells) +- Local steering for obstacle avoidance (RVO) +- Update flow field when goal changes or obstacles appear +- Works with formations: units maintain relative positions while following field + +**Performance**: 1000 units at 60 FPS on 2007 hardware. + +**Key lesson**: For crowds moving to same goal, flow fields are 100× faster than individual pathfinding. + +### Example 4: Google Maps / Waze - Real-Time Traffic Routing + +**Scale**: Millions of vehicles, continent-scale road networks, real-time updates. + +**Approach**: +1. **Contraction hierarchies**: Preprocess road network into hierarchy + - Fast queries: microseconds for cross-country routes + - Update on traffic: recompute affected shortcuts + +2. **Live traffic data**: Crowdsourced vehicle speeds + - Updates every 1-5 minutes + - Edge costs = current measured travel time (not distance) + +3. **Predictive routing**: Machine learning predicts future congestion + - Route calculated for expected conditions at arrival time + - "Leave at 5pm" vs "leave now" gives different routes + +4. **Alternate routes**: Show multiple options with tradeoffs + - Fastest vs shortest vs avoiding highways + - Let user choose based on preferences + +**Key lesson**: Real-world traffic routing is a solved problem. Use contraction hierarchies + live data + prediction. + +### Example 5: Crowd Evacuation Simulation (Real-World Safety) + +**Use case**: Simulating emergency evacuation of stadiums, buildings, cities. + +**Requirements**: +- 10,000+ people +- Real-time or faster-than-real-time +- Accurate crowd dynamics (pushing, bottlenecks) + +**Approach**: +1. **Multi-level pathfinding**: + - Global: Flow field to nearest exit + - Local: RVO (Reciprocal Velocity Obstacles) for collision avoidance + +2. **Bottleneck detection**: + - Monitor flow rate through doorways + - Detect crushing hazards (density > threshold) + - Suggest improvements (widen doors, add exits) + +3. **Panic modeling**: + - Agents push harder when panicked (higher speed, lower personal space) + - May ignore alternate routes (follow crowd) + +4. **Validation**: + - Compare to real evacuation drills + - Calibrate agent parameters to match human behavior + +**Key lesson**: Life-safety simulations require validation against real-world data. Can't just implement A* and call it done. + + +## Cross-References + +### Related Skills + +**[Performance Optimization]** (same skillpack): +- Profiling pathfinding bottlenecks +- Memory pooling for path objects +- Cache-friendly data structures + +**[Crowd Simulation]** (same skillpack): +- Local steering behaviors (RVO, boids) +- Formation movement +- Flocking and swarming + +**[State Machines]** (game-ai skillpack): +- Vehicle states: idle, pathfinding, following_path, stuck, rerouting +- State transitions based on path validity + +**[Spatial Partitioning]** (data-structures skillpack): +- Quadtrees for neighbor queries +- Spatial hashing for collision detection +- Grid-based broad phase + +### External Resources + +**Academic**: +- "Cooperative Pathfinding" by David Silver (flow fields, hierarchical) +- "Predictive Animation and Planning for Virtual Characters" (crowd dynamics) +- Amit's A* Pages (http://theory.stanford.edu/~amitp/GameProgramming/) - industry-standard A* reference + +**Tools**: +- Recast/Detour: Open-source NavMesh library +- Unity NavMesh: Built-in pathfinding (Unity) +- Unreal Navigation System: Built-in (Unreal Engine) + +**Industry talks**: +- "Killzone 2 AI" (GDC) - hierarchical pathfinding +- "Supreme Commander: Forged Alliance" (GDC) - flow fields for 1000+ units +- "Cities Skylines" traffic system (various talks) + + +## Testing Checklist + +Use this checklist to verify your pathfinding implementation is production-ready: + +### Performance Tests + +- [ ] **Frame budget**: Pathfinding stays under 5ms per frame (60 FPS target) +- [ ] **Scaling**: Test with 10×, 100×, 1000× agent counts. Should degrade gracefully. +- [ ] **Large maps**: Test on maximum map size. Long paths shouldn't cause hitches. +- [ ] **Profiling**: Measure time per path calculation. Identify bottlenecks. +- [ ] **Memory**: No memory leaks. Path objects properly pooled/reused. + +### Correctness Tests + +- [ ] **Valid paths**: Paths avoid obstacles and stay on walkable terrain +- [ ] **Optimal paths**: Paths are shortest or near-shortest (within 5% of optimal) +- [ ] **Unreachable destinations**: Graceful fallback when no path exists +- [ ] **Dynamic obstacles**: Paths update when obstacles appear/move +- [ ] **Multi-level terrain**: Works with bridges, overpasses, slopes + +### Traffic Tests (if applicable) + +- [ ] **Congestion handling**: Vehicles reroute around traffic jams +- [ ] **No deadlocks**: Vehicles don't get stuck in gridlock +- [ ] **Traffic lights**: Vehicles respect signals at intersections +- [ ] **Lane usage**: Multi-lane roads distribute traffic across lanes +- [ ] **Merging**: Vehicles merge smoothly onto highways + +### Robustness Tests + +- [ ] **Edge cases**: Empty map, single tile, no valid path, destination = start +- [ ] **Stress test**: 10,000 agents pathfinding simultaneously +- [ ] **Rapid changes**: Add/remove obstacles rapidly, paths stay valid +- [ ] **Long running**: No degradation after 1 hour of simulation +- [ ] **Pathological cases**: Worst-case scenarios (maze, spiral, etc.) + +### Quality Tests + +- [ ] **Visual smoothness**: Agents move naturally, not robotic +- [ ] **Collision avoidance**: Agents don't overlap (unless intended) +- [ ] **Formation movement**: Groups stay together when moving +- [ ] **Responsive**: Path recalculation feels immediate (< 100ms perceived latency) +- [ ] **Believable**: Traffic/crowd behavior looks realistic + +### Integration Tests + +- [ ] **Save/load**: Paths serialize/deserialize correctly +- [ ] **Multiplayer**: Deterministic pathfinding (same inputs = same paths) +- [ ] **Modding**: Expose pathfinding API for modders +- [ ] **Debugging**: Visualize paths, flow fields, congestion heat maps +- [ ] **Configuration**: Exposed parameters (search limits, timeouts, etc.) + + +## Summary + +**Traffic and pathfinding is about intelligent tradeoffs**: +- **Exact vs approximate**: Not every agent needs perfect paths +- **Computation now vs later**: Cache expensive calculations +- **Individual vs group**: Flow fields for crowds, A* for individuals +- **Static vs dynamic**: Balance path quality with recalculation cost + +**The most critical insight**: **Never recalculate paths every frame.** This single mistake causes 90% of pathfinding performance problems. Always cache, always queue, always time-slice. + +**When implementing**: +1. Start with simple A*, measure performance +2. Add hierarchy if map > 5,000 nodes +3. Add flow fields if > 50 agents share destination +4. Add async pathfinding if frame rate drops +5. Add LOD if camera distance varies + +**Architecture matters**: Separate pathfinding from movement. Vehicle shouldn't know HOW path is calculated, only that it requests a path and receives waypoints. This allows swapping algorithms without changing vehicle code. + +**Test at scale early**: 10 agents works very differently than 1000 agents. Don't optimize prematurely, but don't wait until beta to test scalability. diff --git a/skills/using-simulation-tactics/weather-and-time.md b/skills/using-simulation-tactics/weather-and-time.md new file mode 100644 index 0000000..4e4c1f1 --- /dev/null +++ b/skills/using-simulation-tactics/weather-and-time.md @@ -0,0 +1,1566 @@ + +# Weather and Time Systems + +**When to use this skill**: When implementing day/night cycles, weather systems, seasonal changes, or time-based gameplay mechanics in games. Critical for survival games, open-world games, farming simulators, and any game where time and weather affect gameplay. + +**What this skill provides**: Comprehensive understanding of time-of-day systems (sun angle calculation, twilight), dynamic weather (rain, snow, fog), performance-optimized particle systems, gameplay integration patterns, seasonal simulation, time acceleration, and visibility management to maintain 60 FPS while keeping the game playable. + + +## Core Concepts + +### Time of Day Systems + +**Solar Angle Calculation** +- **Real-world sun path**: Sun rises in east, peaks at south (northern hemisphere), sets in west +- **Solar elevation angle**: Varies with latitude and season (0° at horizon, 90° at zenith) +- **Azimuth angle**: Horizontal direction (0° = north, 90° = east, 180° = south, 270° = west) +- **Use case**: Realistic sun movement, shadow direction, day length variation + +**Twilight Phases** +- **Civil twilight**: Sun 0° to -6° below horizon (still visible light) +- **Nautical twilight**: Sun -6° to -12° below horizon (horizon barely visible) +- **Astronomical twilight**: Sun -12° to -18° below horizon (darkest before true night) +- **Gameplay impact**: Smooth transition from day to night (no jarring darkness) + +**Time Acceleration** +- **Real-time**: 1 second game = 1 second real-time (Animal Crossing) +- **Accelerated**: 1 minute game = 1 day in-game (Minecraft: 20 minutes = 24 hours) +- **Player-controlled**: Variable speed (Stardew Valley: speed up while sleeping) +- **Paused time**: Strategic games pause time during combat (XCOM) + +### Weather Systems + +**Weather Types and Properties** +- **Clear**: High visibility (1000m+), no movement penalty, minimal particles +- **Rain**: Reduced visibility (500m), slight movement penalty, moderate particles +- **Heavy Rain**: Low visibility (200m), movement penalty, high particles +- **Snow**: Moderate visibility (300m), movement penalty, moderate particles +- **Blizzard**: Very low visibility (100m), major movement penalty, high particles +- **Fog**: Very low visibility (50m), no movement penalty, zero particles (post-process) +- **Thunderstorm**: Periodic flashes, audio cues, lightning strikes + +**Weather Simulation Models** + +**Static Weather**: Pre-scripted weather patterns +- **Use case**: Scripted story moments, performance-critical scenarios +- **Pros**: Predictable, performant, no simulation cost +- **Cons**: Repetitive, not dynamic + +**Procedural Weather**: Dynamic simulation based on rules +- **Use case**: Open-world games, long play sessions +- **Pros**: Varied, emergent patterns, replayability +- **Cons**: Can be unpredictable, requires tuning + +**Markov Chain Weather**: Probability-based state transitions +- **Method**: Each weather type has transition probabilities to other types +- **Example**: Clear 70% → Clear, 20% → Cloudy, 10% → Rain +- **Pros**: Realistic patterns, controllable, prevents rapid oscillation +- **Cons**: Requires weather state machine + +**Seasonal Variation** +- **Spring**: High rain probability, moderate temperatures +- **Summer**: High clear probability, hot temperatures +- **Fall**: Moderate rain, temperature drops +- **Winter**: High snow probability, cold temperatures, shorter days + +### Particle System Optimization + +**Performance Budgets** +- **Target frame time**: 16.67ms for 60 FPS +- **Particle budget**: 1-2ms per frame (5-10% of frame time) +- **Maximum particles**: 1,000-5,000 depending on platform (PC higher, mobile lower) +- **Particle update cost**: ~0.001ms per particle (CPU), ~0.0002ms (GPU) + +**Level of Detail (LOD)** +- **Near camera (0-50m)**: Full particle density, full physics +- **Medium distance (50-200m)**: 50% particle density, simplified physics +- **Far distance (200m+)**: 25% particle density, no physics (billboards) +- **Out of view**: No particles (frustum culling) + +**Object Pooling** +- **Problem**: Creating/destroying particles every frame causes GC pressure +- **Solution**: Pre-allocate pool, recycle particles +- **Pool size**: 2× maximum active particles (allows burst without allocation) + +**Spatial Culling** +- **Frustum culling**: Don't render particles outside camera view +- **Distance culling**: Don't simulate particles beyond visibility range +- **Occlusion culling**: Don't render particles behind solid objects + +### Visibility Management + +**Gameplay Visibility vs Atmospheric Realism** +- **Problem**: Realistic night is pitch black (unplayable) +- **Solution**: Minimum ambient light for gameplay (0.15-0.25 even at midnight) +- **Trick**: Blue tint simulates moonlight without true darkness + +**Fog Distance Curve** +- **Linear fog**: Visibility fades linearly with distance +- **Exponential fog**: Realistic, visibility drops off exponentially +- **Exponential squared**: Most realistic, dense fog feel +- **Gameplay consideration**: Too much fog frustrates players, balance with gameplay needs + +**Dynamic Visibility** +- **Weather-based**: Rain/snow/fog reduce visibility distance +- **Time-based**: Night reduces visibility (but not below minimum) +- **Additive**: Multiple factors combine (night + fog = very limited visibility) + + +## Decision Frameworks + +### Framework 1: Real-Time vs Accelerated Time + +``` +START: What type of game am I building? + +├─ REAL-TIME EXPERIENCE? +│ ├─ Social/multiplayer game? → Use REAL-TIME CLOCK +│ │ - Animal Crossing: Real-world time = game time +│ │ - MMOs: Synchronized time across players +│ │ - Pros: Shared experience, event scheduling +│ │ - Cons: Players can't skip boring parts +│ │ +│ └─ Single-player atmospheric? → Use REAL-TIME with PAUSE +│ - Survival horror: Real-time tension +│ - Flight simulators: Real-time weather +│ - Add pause/fast-forward for player convenience +│ +├─ GAMEPLAY PACING REQUIRES FAST TIME? +│ ├─ Farming/crafting mechanics? → Use ACCELERATED TIME +│ │ - Stardew Valley: 1 second real = 1 minute game +│ │ - Minecraft: 20 minutes real = 1 day game +│ │ - Allows crops to grow in reasonable playtime +│ │ - Players experience full day/night in one session +│ │ +│ └─ Long-term progression? → Use ACCELERATED with SLEEP +│ - Skip to next day when sleeping +│ - Time passes while player away (mobile games) +│ +├─ STRATEGIC/TURN-BASED? +│ └─ Use PAUSED TIME during decisions +│ - XCOM: Time stops during combat turns +│ - Civilization: Discrete turns (day/night cosmetic only) +│ - RTS: Can pause and issue orders +│ +└─ PLAYER CHOICE? + └─ Provide TIME CONTROL UI + - Speed slider: 1x, 2x, 5x, 10x + - "Wait" button: Skip to morning/night + - Strategic layer: Pause/slow/fast +``` + +**Example Decision**: Survival game with crafting +- **Chosen**: Accelerated time (30 min real = 24 hours game) +- **Reasoning**: Players need full day/night cycle per session, crafting takes minutes not hours +- **Time control**: Let players sleep to skip night, speed up to 3x when safe + +### Framework 2: Static vs Dynamic Weather + +``` +START: How important is weather to my gameplay? + +├─ WEATHER IS COSMETIC ONLY? +│ └─ Use STATIC WEATHER with scripted changes +│ - Pre-defined weather for each level/mission +│ - Lighter on performance +│ - Predictable for testing +│ - Example: Linear FPS games (scripted rain in mission 3) +│ +├─ WEATHER AFFECTS GAMEPLAY SIGNIFICANTLY? +│ ├─ Short sessions (< 1 hour)? → Use PROCEDURAL with SHORT CYCLE +│ │ - Ensure players experience weather variety +│ │ - Faster transitions (10-15 minute cycles) +│ │ - Example: PUBG (random weather per match) +│ │ +│ └─ Long sessions (1+ hours)? → Use PROCEDURAL with REALISTIC CYCLE +│ - Slower transitions (30-60 minute cycles) +│ - Weather affects strategy (take cover in storm) +│ - Example: Zelda BOTW (dynamic weather with gameplay effects) +│ +├─ SEASONAL PROGRESSION IMPORTANT? +│ └─ Use SEASONAL SYSTEM with weather probability curves +│ - Spring: 60% rain, 30% clear, 10% cloudy +│ - Summer: 70% clear, 20% cloudy, 10% rain +│ - Fall: 40% clear, 40% cloudy, 20% rain +│ - Winter: 50% snow, 30% clear, 20% overcast +│ +└─ MULTIPLAYER SYNCHRONIZATION NEEDED? + └─ Use SERVER-AUTHORITATIVE weather + - Server decides weather, clients render + - All players see same weather + - Important for competitive games (fair visibility) +``` + +**Example Decision**: Open-world survival game +- **Chosen**: Dynamic procedural weather with seasonal variation +- **Reasoning**: Long sessions, weather affects gameplay (shelter needed in rain) +- **Implementation**: Markov chain transitions, 45-min average weather duration + +### Framework 3: Cosmetic vs Gameplay-Affecting Weather + +``` +START: Should weather affect gameplay? + +├─ WEATHER AS ATMOSPHERE ONLY? +│ └─ Cosmetic effects +│ - Visual particles (rain, snow) +│ - Audio (thunder, wind) +│ - No mechanical effects +│ - Use when: Story-driven games where consistency > variance +│ +├─ WEATHER AS MINOR MODIFIER? +│ └─ Subtle gameplay effects +│ - Visibility slightly reduced (800m → 600m) +│ - Movement speed -5% in rain +│ - Audio masking (harder to hear enemies) +│ - Use when: Competitive games (small effects for fairness) +│ +├─ WEATHER AS MAJOR MECHANIC? +│ └─ Significant gameplay effects +│ - Visibility heavily reduced (fog: 1000m → 100m) +│ - Movement penalty (snow: -25% speed) +│ - Health effects (hypothermia in blizzard) +│ - Resource requirements (shelter from rain) +│ - Use when: Survival games, tactical advantages +│ +└─ WEATHER AS CORE SYSTEM? + └─ Central gameplay pillar + - Plan activities around weather + - Specific gear for weather types + - Weather-dependent quests + - Example: Death Stranding (rain damages cargo) + - Example: Rain World (rain cycle is core mechanic) +``` + +**Decision Matrix**: +| Game Type | Weather Role | Visibility Impact | Movement Impact | Example | +|-----------|-------------|------------------|----------------|---------| +| Arena FPS | Cosmetic | None | None | Quake | +| Battle Royale | Minor | -20% range | None | PUBG | +| Open-world | Major | -50% in fog | -25% in snow | Zelda BOTW | +| Survival | Core | -70% in blizzard | -40% | The Long Dark | + +**Example Decision**: Survival game +- **Chosen**: Weather as major mechanic +- **Effects**: Rain requires shelter, snow reduces movement, fog limits visibility +- **Balance**: Weather gives strategic depth but doesn't feel unfair + + +## Implementation Patterns + +### Pattern 1: Smooth Time-of-Day System with Solar Math + +**Problem**: Hardcoded hour-to-angle mapping looks unnatural. Binary day/night creates jarring transitions. + +**Solution**: Calculate sun position using realistic solar math, implement twilight phases. + +```python +import math + +class TimeOfDaySystem: + def __init__(self, day_length_seconds=1200, latitude=45.0): + """ + day_length_seconds: Real-time seconds for full 24-hour cycle + latitude: Degrees north (0-90) affects day length and sun angle + """ + self.day_length_seconds = day_length_seconds + self.latitude_rad = math.radians(latitude) + self.current_time = 12.0 # Hours (0-24), start at noon + self.day_of_year = 172 # Day 172 = summer solstice (longest day) + self.time_scale = 1.0 # Multiplier for acceleration + + def update(self, delta_time): + """Update time, wrapping at 24 hours""" + hours_per_second = 24.0 / self.day_length_seconds + self.current_time += delta_time * hours_per_second * self.time_scale + + if self.current_time >= 24.0: + self.current_time -= 24.0 + self.day_of_year = (self.day_of_year + 1) % 365 + + def get_sun_position(self): + """Calculate sun elevation and azimuth angles""" + # Solar declination (tilt of Earth's axis) + # Ranges from -23.44° (winter) to +23.44° (summer) + day_angle = 2 * math.pi * (self.day_of_year - 81) / 365 + declination = math.radians(23.44) * math.sin(day_angle) + + # Hour angle: Sun's position relative to solar noon + # -180° at midnight, 0° at noon, +180° at next midnight + hour_angle = math.radians(15.0 * (self.current_time - 12.0)) + + # Solar elevation angle (altitude) + sin_elevation = (math.sin(self.latitude_rad) * math.sin(declination) + + math.cos(self.latitude_rad) * math.cos(declination) * + math.cos(hour_angle)) + elevation = math.asin(max(-1, min(1, sin_elevation))) + + # Solar azimuth angle + cos_azimuth = ((math.sin(declination) - + math.sin(self.latitude_rad) * sin_elevation) / + (math.cos(self.latitude_rad) * math.cos(elevation))) + cos_azimuth = max(-1, min(1, cos_azimuth)) + azimuth = math.acos(cos_azimuth) + + # Correct azimuth for afternoon (hour angle > 0) + if hour_angle > 0: + azimuth = 2 * math.pi - azimuth + + return math.degrees(elevation), math.degrees(azimuth) + + def get_ambient_light_factor(self): + """Calculate ambient light with twilight transitions""" + elevation, _ = self.get_sun_position() + + if elevation > 0: + # Daytime: Full light + return 1.0 + elif elevation > -6: + # Civil twilight: Smooth transition + t = (elevation + 6) / 6 # 0 to 1 + return 0.25 + (0.75 * t) # 0.25 to 1.0 + elif elevation > -12: + # Nautical twilight + t = (elevation + 12) / 6 + return 0.15 + (0.10 * t) # 0.15 to 0.25 + else: + # Night: Minimum light for gameplay + return 0.15 # Never truly black (moonlight simulation) + + def get_sun_color(self): + """Calculate sun color based on elevation""" + elevation, _ = self.get_sun_position() + + if elevation > 30: + # High sun: White + return (1.0, 1.0, 0.95) + elif elevation > 0: + # Low sun: Orange-red + t = elevation / 30 + return (1.0, 0.6 + 0.4*t, 0.3 + 0.65*t) + elif elevation > -6: + # Sunset: Deep red + t = (elevation + 6) / 6 + return (0.9 + 0.1*t, 0.3*t, 0.1*t) + else: + # Night: Dark blue (moonlight) + return (0.2, 0.3, 0.5) + + def set_time_scale(self, scale): + """Change time acceleration: 0=paused, 1=normal, 2+=faster""" + self.time_scale = max(0, scale) + + def skip_to_time(self, target_hour): + """Instantly jump to specific time (for 'wait' or 'sleep' actions)""" + self.current_time = target_hour % 24.0 +``` + +**Benefits**: +- Realistic sun movement (rises east, sets west) +- Smooth twilight transitions (no jarring darkness) +- Day length varies with season (longer summer days) +- Minimum ambient light (0.15) keeps game playable at night +- Configurable latitude and day length + +**When to use**: +- Open-world games with visual realism +- Games where sun position matters (shadows, solar panels) +- Long play sessions where players experience full day + +**Real-world example**: Red Dead Redemption 2 uses accurate solar calculations for realistic lighting throughout the day. + +### Pattern 2: Budget-Constrained Particle System with LOD + +**Problem**: Naive particle systems spawn unlimited particles, causing FPS death. + +**Solution**: Enforce hard particle limit, use LOD based on distance, pool objects. + +```python +import random +from collections import deque + +class Particle: + def __init__(self): + self.x = 0.0 + self.y = 0.0 + self.z = 0.0 + self.vx = 0.0 + self.vy = 0.0 + self.vz = 0.0 + self.lifetime = 0.0 + self.max_lifetime = 1.0 + self.active = False + +class WeatherParticleSystem: + def __init__(self, max_particles=5000): + self.max_particles = max_particles + + # Pre-allocate particle pool (NO allocations during gameplay) + self.particle_pool = [Particle() for _ in range(max_particles)] + self.active_particles = [] + self.free_particles = deque(self.particle_pool) + + # LOD settings + self.lod_near = 50.0 # Full density + self.lod_medium = 150.0 # 50% density + self.lod_far = 300.0 # 25% density + + # Performance tracking + self.time_budget_ms = 2.0 # Max 2ms per frame + self.spawn_this_frame = 0 + + def spawn_rain(self, camera_pos, intensity, delta_time): + """Spawn rain particles with LOD and budget constraints""" + # Calculate spawn budget based on frame time + base_spawn_rate = intensity * 1000 # Particles per second + desired_spawn = int(base_spawn_rate * delta_time) + + # LOD: Reduce spawn in layers by distance + near_spawn = desired_spawn # Full density near camera + medium_spawn = desired_spawn // 2 # 50% at medium distance + far_spawn = desired_spawn // 4 # 25% at far distance + + # Enforce particle cap + available_slots = self.max_particles - len(self.active_particles) + total_spawn = min(near_spawn + medium_spawn + far_spawn, available_slots) + + if total_spawn == 0: + return # At capacity or no budget + + spawned = 0 + + # Spawn near particles (0-50m) + for _ in range(min(near_spawn, available_slots)): + if not self.free_particles: + break + + particle = self.free_particles.popleft() + self._initialize_rain_particle(particle, camera_pos, self.lod_near) + self.active_particles.append(particle) + spawned += 1 + + # Spawn medium particles (50-150m) + for _ in range(min(medium_spawn, available_slots - spawned)): + if not self.free_particles: + break + + particle = self.free_particles.popleft() + self._initialize_rain_particle(particle, camera_pos, self.lod_medium, + offset_min=self.lod_near) + self.active_particles.append(particle) + spawned += 1 + + # Spawn far particles (150-300m) - only if budget allows + remaining_budget = available_slots - spawned + for _ in range(min(far_spawn, remaining_budget)): + if not self.free_particles: + break + + particle = self.free_particles.popleft() + self._initialize_rain_particle(particle, camera_pos, self.lod_far, + offset_min=self.lod_medium) + self.active_particles.append(particle) + + def _initialize_rain_particle(self, particle, camera_pos, max_distance, + offset_min=0.0): + """Initialize a rain particle at random position""" + # Random position in cylindrical volume around camera + angle = random.uniform(0, 2 * 3.14159) + distance = random.uniform(offset_min, max_distance) + + particle.x = camera_pos[0] + distance * math.cos(angle) + particle.z = camera_pos[2] + distance * math.sin(angle) + particle.y = camera_pos[1] + random.uniform(30, 50) # High in sky + + # Rain falls straight down with slight wind + particle.vx = random.uniform(-0.5, 0.5) + particle.vy = -10.0 # Fall speed + particle.vz = random.uniform(-0.5, 0.5) + + particle.lifetime = 0.0 + particle.max_lifetime = random.uniform(3.0, 5.0) + particle.active = True + + def update(self, delta_time, camera_pos): + """Update all active particles with time budget""" + import time + start_time = time.perf_counter() + + particles_to_remove = [] + + for particle in self.active_particles: + # Update position + particle.x += particle.vx * delta_time + particle.y += particle.vy * delta_time + particle.z += particle.vz * delta_time + + particle.lifetime += delta_time + + # Remove if below ground or lifetime expired + if particle.y < 0 or particle.lifetime > particle.max_lifetime: + particle.active = False + particles_to_remove.append(particle) + continue + + # Distance culling: Remove particles far from camera + dx = particle.x - camera_pos[0] + dz = particle.z - camera_pos[2] + dist_sq = dx*dx + dz*dz + + if dist_sq > self.lod_far * self.lod_far: + particle.active = False + particles_to_remove.append(particle) + + # Budget check: Stop updating if over time budget + elapsed_ms = (time.perf_counter() - start_time) * 1000 + if elapsed_ms > self.time_budget_ms: + break # Update remaining particles next frame + + # Return particles to pool + for particle in particles_to_remove: + self.active_particles.remove(particle) + self.free_particles.append(particle) + + def get_particle_count(self): + return len(self.active_particles) +``` + +**Benefits**: +- Hard cap on particles (5,000 max) prevents unbounded growth +- Object pooling eliminates GC pressure (zero allocations during gameplay) +- LOD reduces particles by distance (75% at 300m) +- Time budget prevents frame time spikes (stops at 2ms) +- Distance culling removes particles outside range + +**Performance**: +- CPU cost: ~1-2ms per frame (5000 particles) +- Memory: Fixed (pre-allocated pool) +- FPS: Stable 60 FPS + +**When to use**: +- Any game with weather particles (rain, snow) +- Performance-critical scenarios (mobile, VR) +- Large open worlds (distance culling essential) + +**Real-world example**: Zelda BOTW limits rain particles to ~3,000, uses heavy LOD culling beyond 100m. + +### Pattern 3: Gameplay-Integrated Weather System + +**Problem**: Weather is cosmetic-only, doesn't affect player strategy or tactics. + +**Solution**: Weather modifies visibility, movement, audio, and environmental hazards. + +```python +from enum import Enum +from dataclasses import dataclass + +class WeatherType(Enum): + CLEAR = "clear" + CLOUDY = "cloudy" + RAIN = "rain" + HEAVY_RAIN = "heavy_rain" + SNOW = "snow" + BLIZZARD = "blizzard" + FOG = "fog" + THUNDERSTORM = "thunderstorm" + +@dataclass +class WeatherProperties: + """Gameplay properties for each weather type""" + visibility_range: float # Meters + movement_modifier: float # 1.0 = normal, 0.75 = 25% slower + audio_masking: float # 0-1, higher = harder to hear + particle_count_multiplier: float # Relative to base + ambient_light_modifier: float # 1.0 = normal, 0.7 = 30% darker + +# Weather property database +WEATHER_PROPERTIES = { + WeatherType.CLEAR: WeatherProperties( + visibility_range=1000.0, + movement_modifier=1.0, + audio_masking=0.0, + particle_count_multiplier=0.0, + ambient_light_modifier=1.0 + ), + WeatherType.RAIN: WeatherProperties( + visibility_range=500.0, + movement_modifier=0.95, + audio_masking=0.3, + particle_count_multiplier=1.0, + ambient_light_modifier=0.85 + ), + WeatherType.HEAVY_RAIN: WeatherProperties( + visibility_range=200.0, + movement_modifier=0.85, + audio_masking=0.6, + particle_count_multiplier=2.0, + ambient_light_modifier=0.7 + ), + WeatherType.SNOW: WeatherProperties( + visibility_range=300.0, + movement_modifier=0.75, # Slow in snow + audio_masking=0.4, + particle_count_multiplier=1.5, + ambient_light_modifier=1.1 # Snow reflects light + ), + WeatherType.BLIZZARD: WeatherProperties( + visibility_range=100.0, + movement_modifier=0.6, # Very slow + audio_masking=0.7, + particle_count_multiplier=3.0, + ambient_light_modifier=0.8 + ), + WeatherType.FOG: WeatherProperties( + visibility_range=50.0, # Very limited + movement_modifier=1.0, # Fog doesn't slow + audio_masking=0.2, + particle_count_multiplier=0.0, # Fog is post-process, no particles + ambient_light_modifier=0.9 + ), +} + +class GameplayWeatherSystem: + def __init__(self): + self.current_weather = WeatherType.CLEAR + self.transition_progress = 1.0 # 0-1, 1=fully transitioned + self.transition_duration = 5.0 # Seconds + self.target_weather = WeatherType.CLEAR + + def change_weather(self, new_weather, transition_time=5.0): + """Smoothly transition to new weather""" + if new_weather == self.current_weather: + return + + self.target_weather = new_weather + self.transition_duration = transition_time + self.transition_progress = 0.0 + + def update(self, delta_time): + """Update weather transition""" + if self.transition_progress < 1.0: + self.transition_progress += delta_time / self.transition_duration + if self.transition_progress >= 1.0: + self.transition_progress = 1.0 + self.current_weather = self.target_weather + + def get_visibility_range(self): + """Get current visibility distance in meters""" + if self.transition_progress >= 1.0: + return WEATHER_PROPERTIES[self.current_weather].visibility_range + + # Interpolate during transition + current_props = WEATHER_PROPERTIES[self.current_weather] + target_props = WEATHER_PROPERTIES[self.target_weather] + + t = self.transition_progress + return current_props.visibility_range * (1-t) + target_props.visibility_range * t + + def get_movement_modifier(self): + """Get movement speed multiplier (1.0 = normal)""" + if self.transition_progress >= 1.0: + return WEATHER_PROPERTIES[self.current_weather].movement_modifier + + current_props = WEATHER_PROPERTIES[self.current_weather] + target_props = WEATHER_PROPERTIES[self.target_weather] + + t = self.transition_progress + return current_props.movement_modifier * (1-t) + target_props.movement_modifier * t + + def get_audio_masking(self): + """Get audio masking factor (0=clear, 1=fully masked)""" + if self.transition_progress >= 1.0: + return WEATHER_PROPERTIES[self.current_weather].audio_masking + + current_props = WEATHER_PROPERTIES[self.current_weather] + target_props = WEATHER_PROPERTIES[self.target_weather] + + t = self.transition_progress + return current_props.audio_masking * (1-t) + target_props.audio_masking * t + + def apply_to_player(self, player): + """Apply weather effects to player""" + # Movement speed + weather_speed = self.get_movement_modifier() + player.movement_speed = player.base_movement_speed * weather_speed + + # Visibility (for AI detection, fog of war) + player.visibility_range = self.get_visibility_range() + + # Audio (for enemy hearing player) + player.audio_masking = self.get_audio_masking() + + def apply_to_camera(self, camera): + """Apply weather effects to camera/rendering""" + # Fog distance for rendering + visibility = self.get_visibility_range() + camera.fog_start = visibility * 0.5 + camera.fog_end = visibility + + # Ambient light modifier + current_props = WEATHER_PROPERTIES[self.current_weather] + target_props = WEATHER_PROPERTIES[self.target_weather] + t = self.transition_progress + + light_mod = (current_props.ambient_light_modifier * (1-t) + + target_props.ambient_light_modifier * t) + camera.ambient_light_scale = light_mod +``` + +**Benefits**: +- Weather directly affects gameplay (movement, visibility, audio) +- Smooth transitions prevent jarring changes +- Easy to balance (modify property values) +- AI can react to weather (seek shelter, change tactics) + +**Gameplay Applications**: +- **Stealth**: Use rain to mask footsteps, fog to avoid detection +- **Combat**: Heavy rain reduces visibility, favors close-range +- **Survival**: Blizzard forces player to find shelter (hypothermia risk) +- **Strategy**: Plan attacks during favorable weather + +**When to use**: +- Survival games (weather is hazard) +- Stealth games (weather affects detection) +- Open-world games (weather adds variety) + +**Real-world example**: Metal Gear Solid V uses rain to mask noise, sandstorms to reduce visibility. + +### Pattern 4: Markov Chain Weather Transitions with Seasonal Variation + +**Problem**: Completely random weather feels unnatural and lacks patterns. + +**Solution**: Use Markov chain with season-dependent transition probabilities. + +```python +import random + +class Season(Enum): + SPRING = 0 + SUMMER = 1 + FALL = 2 + WINTER = 3 + +class WeatherSimulation: + def __init__(self): + self.current_weather = WeatherType.CLEAR + self.current_season = Season.SPRING + self.time_in_current_weather = 0.0 + self.min_weather_duration = 300.0 # 5 minutes minimum + + # Transition probability matrices (current → next) + # Rows: current weather, Columns: next weather + # Order: CLEAR, CLOUDY, RAIN, HEAVY_RAIN, SNOW, BLIZZARD, FOG + + self.transition_probabilities = { + Season.SPRING: { + WeatherType.CLEAR: [0.4, 0.4, 0.15, 0.05, 0.0, 0.0, 0.0], + WeatherType.CLOUDY: [0.3, 0.3, 0.3, 0.1, 0.0, 0.0, 0.0], + WeatherType.RAIN: [0.2, 0.3, 0.4, 0.1, 0.0, 0.0, 0.0], + WeatherType.HEAVY_RAIN: [0.1, 0.2, 0.5, 0.2, 0.0, 0.0, 0.0], + WeatherType.FOG: [0.3, 0.3, 0.2, 0.0, 0.0, 0.0, 0.2], + }, + Season.SUMMER: { + WeatherType.CLEAR: [0.7, 0.2, 0.05, 0.05, 0.0, 0.0, 0.0], + WeatherType.CLOUDY: [0.5, 0.3, 0.15, 0.05, 0.0, 0.0, 0.0], + WeatherType.RAIN: [0.3, 0.3, 0.3, 0.1, 0.0, 0.0, 0.0], + WeatherType.HEAVY_RAIN: [0.2, 0.2, 0.4, 0.2, 0.0, 0.0, 0.0], # Storms + WeatherType.THUNDERSTORM: [0.1, 0.2, 0.3, 0.4, 0.0, 0.0, 0.0], + }, + Season.FALL: { + WeatherType.CLEAR: [0.4, 0.4, 0.1, 0.05, 0.0, 0.0, 0.05], + WeatherType.CLOUDY: [0.3, 0.4, 0.2, 0.05, 0.0, 0.0, 0.05], + WeatherType.RAIN: [0.2, 0.3, 0.4, 0.1, 0.0, 0.0, 0.0], + WeatherType.FOG: [0.2, 0.3, 0.1, 0.0, 0.0, 0.0, 0.4], + }, + Season.WINTER: { + WeatherType.CLEAR: [0.5, 0.2, 0.0, 0.0, 0.25, 0.05, 0.0], + WeatherType.CLOUDY: [0.3, 0.3, 0.0, 0.0, 0.35, 0.05, 0.0], + WeatherType.SNOW: [0.2, 0.2, 0.0, 0.0, 0.5, 0.1, 0.0], + WeatherType.BLIZZARD: [0.1, 0.1, 0.0, 0.0, 0.6, 0.2, 0.0], + }, + } + + # Weather types in order for indexing + self.weather_types = [ + WeatherType.CLEAR, + WeatherType.CLOUDY, + WeatherType.RAIN, + WeatherType.HEAVY_RAIN, + WeatherType.SNOW, + WeatherType.BLIZZARD, + WeatherType.FOG, + ] + + def update(self, delta_time): + """Update weather simulation""" + self.time_in_current_weather += delta_time + + # Only consider transition after minimum duration + if self.time_in_current_weather < self.min_weather_duration: + return + + # Check for transition (1% chance per second after minimum) + transition_chance = delta_time * 0.01 + if random.random() < transition_chance: + self._transition_weather() + self.time_in_current_weather = 0.0 + + def _transition_weather(self): + """Choose next weather based on Markov chain""" + season_probs = self.transition_probabilities[self.current_season] + + if self.current_weather not in season_probs: + # Current weather not valid for season, force to CLEAR + self.current_weather = WeatherType.CLEAR + return + + probabilities = season_probs[self.current_weather] + + # Weighted random choice + rand_val = random.random() + cumulative = 0.0 + + for i, prob in enumerate(probabilities): + cumulative += prob + if rand_val < cumulative: + self.current_weather = self.weather_types[i] + break + + def set_season(self, season): + """Change season, may trigger immediate weather change""" + old_season = self.current_season + self.current_season = season + + # Check if current weather is valid for new season + season_probs = self.transition_probabilities[season] + if self.current_weather not in season_probs: + # Force transition to valid weather + self._transition_weather() +``` + +**Benefits**: +- Natural weather patterns (not purely random) +- Seasonal variation (snow in winter, not summer) +- Prevents rapid oscillation (minimum duration) +- Controllable probabilities (easy to tune) + +**Pattern Characteristics**: +- **Spring**: Rainy (60% chance), moderate temperatures +- **Summer**: Clear and hot (70% clear), occasional thunderstorms +- **Fall**: Cloudy and foggy, transition to cold +- **Winter**: Snow and blizzards (40% snow), cold + +**When to use**: +- Open-world games with seasons +- Long play sessions (players notice patterns) +- Realistic simulation games + +**Real-world example**: Animal Crossing uses Markov-like weather with seasonal variation. + +### Pattern 5: Time Control UI and "Wait" Mechanic + +**Problem**: Players forced to wait through boring periods (night, storms). + +**Solution**: Provide time acceleration controls and "wait until" actions. + +```python +class TimeControlSystem: + def __init__(self, time_of_day_system, weather_system): + self.time_system = time_of_day_system + self.weather_system = weather_system + + self.available_speeds = [0.0, 0.5, 1.0, 2.0, 5.0, 10.0] # 0=pause + self.current_speed_index = 2 # Start at 1.0x + + self.waiting = False + self.wait_target_hour = None + self.wait_callback = None + + def increase_speed(self): + """Increase time scale (up to 10x)""" + if self.current_speed_index < len(self.available_speeds) - 1: + self.current_speed_index += 1 + self.time_system.set_time_scale( + self.available_speeds[self.current_speed_index] + ) + + def decrease_speed(self): + """Decrease time scale (down to pause)""" + if self.current_speed_index > 0: + self.current_speed_index -= 1 + self.time_system.set_time_scale( + self.available_speeds[self.current_speed_index] + ) + + def set_normal_speed(self): + """Reset to 1x speed""" + self.current_speed_index = 2 # 1.0x + self.time_system.set_time_scale(1.0) + + def wait_until_morning(self, callback=None): + """Fast-forward to next morning (6 AM)""" + self.wait_target_hour = 6.0 + self.waiting = True + self.wait_callback = callback + + # Accelerate time during wait + self.time_system.set_time_scale(60.0) # 60x speed + + def wait_until_night(self, callback=None): + """Fast-forward to next night (8 PM)""" + self.wait_target_hour = 20.0 + self.waiting = True + self.wait_callback = callback + self.time_system.set_time_scale(60.0) + + def wait_for_hours(self, hours, callback=None): + """Wait for specific number of hours""" + target = (self.time_system.current_time + hours) % 24.0 + self.wait_target_hour = target + self.waiting = True + self.wait_callback = callback + self.time_system.set_time_scale(60.0) + + def update(self, delta_time): + """Check if wait target reached""" + if not self.waiting: + return + + current = self.time_system.current_time + target = self.wait_target_hour + + # Check if we've passed the target hour + # Handle wraparound (23:00 → 6:00) + if target > current: + if current >= target: + self._complete_wait() + else: + # Wrapped around midnight + if current >= target and current < target + 1.0: + self._complete_wait() + + def _complete_wait(self): + """Finish waiting, restore normal time""" + self.waiting = False + self.set_normal_speed() + + if self.wait_callback: + self.wait_callback() + self.wait_callback = None + + def can_wait(self, player): + """Check if player can wait (safe location, not in combat)""" + if player.in_combat: + return False, "Cannot wait during combat" + + if player.enemies_nearby(): + return False, "Enemies nearby" + + if not player.in_safe_zone(): + return False, "Not in safe location" + + return True, "" + +# Example UI integration +class TimeControlUI: + def __init__(self, time_control): + self.time_control = time_control + + def render_time_controls(self, ui): + """Render time speed controls""" + speeds = time_control.available_speeds + current_idx = time_control.current_speed_index + + ui.label(f"Time Speed: {speeds[current_idx]}x") + + if ui.button("<<"): # Slower + time_control.decrease_speed() + + if ui.button("||"): # Pause + time_control.current_speed_index = 0 + time_control.time_system.set_time_scale(0.0) + + if ui.button(">>"): # Faster + time_control.increase_speed() + + def render_wait_options(self, ui, player): + """Render wait/sleep menu""" + can_wait, reason = time_control.can_wait(player) + + if not can_wait: + ui.label(f"Cannot wait: {reason}", color="red") + return + + if ui.button("Wait until morning (6 AM)"): + time_control.wait_until_morning( + callback=lambda: player.restore_energy(50) + ) + + if ui.button("Wait until night (8 PM)"): + time_control.wait_until_night() + + if ui.button("Wait 1 hour"): + time_control.wait_for_hours(1) + + if ui.button("Wait 4 hours"): + time_control.wait_for_hours(4) +``` + +**Benefits**: +- Players skip boring periods (night, waiting for shop to open) +- Strategic use (wait for enemies to leave area) +- Performance optimization (faster time = fewer frames to render) +- Quality-of-life feature (respects player's time) + +**Design Considerations**: +- Only allow waiting in safe locations (no combat exploit) +- Restore energy/health during wait (reward for using feature) +- Fast-forward at 60x speed (visible but quick) +- Stop if interrupted (enemy appears) + +**When to use**: +- Games with day/night cycles +- Survival games (wait for weather to clear) +- RPGs (wait for shops to open) + +**Real-world examples**: +- Skyrim: "Wait" menu to skip time +- Stardew Valley: Sleep to skip to next day +- Zelda BOTW: Campfire rest to skip to morning/night + +### Pattern 6: Fog as Post-Process Effect (Zero Particles) + +**Problem**: Fog with particles is expensive and looks bad. + +**Solution**: Use distance-based post-process fog (no particles needed). + +```python +class FogSystem: + def __init__(self): + self.fog_enabled = False + self.fog_density = 0.0 # 0-1 + self.fog_color = (0.7, 0.7, 0.75) # Gray-white + self.fog_start = 10.0 # Meters + self.fog_end = 100.0 # Meters + + def update_fog_from_weather(self, weather_type): + """Set fog based on weather""" + if weather_type == WeatherType.FOG: + self.fog_enabled = True + self.fog_density = 0.8 + self.fog_start = 5.0 + self.fog_end = 50.0 + self.fog_color = (0.7, 0.7, 0.75) + + elif weather_type == WeatherType.RAIN: + self.fog_enabled = True + self.fog_density = 0.3 + self.fog_start = 50.0 + self.fog_end = 500.0 + self.fog_color = (0.6, 0.6, 0.7) + + elif weather_type == WeatherType.BLIZZARD: + self.fog_enabled = True + self.fog_density = 0.9 + self.fog_start = 10.0 + self.fog_end = 100.0 + self.fog_color = (0.9, 0.9, 1.0) # White + + else: + self.fog_enabled = False + + def get_shader_parameters(self): + """Get parameters for fog shader""" + return { + 'fog_enabled': self.fog_enabled, + 'fog_color': self.fog_color, + 'fog_start': self.fog_start, + 'fog_end': self.fog_end, + 'fog_density': self.fog_density, + } + +# Fragment shader (GLSL) for exponential fog +""" +uniform bool fog_enabled; +uniform vec3 fog_color; +uniform float fog_start; +uniform float fog_end; +uniform float fog_density; + +void main() { + vec3 color = texture(scene_texture, uv).rgb; + + if (fog_enabled) { + float distance = length(frag_position - camera_position); + + // Exponential squared fog (most realistic) + float fog_factor = distance / fog_end; + fog_factor = exp(-fog_density * fog_factor * fog_factor); + fog_factor = clamp(fog_factor, 0.0, 1.0); + + // Blend between scene color and fog color + color = mix(fog_color, color, fog_factor); + } + + frag_color = vec4(color, 1.0); +} +""" +``` + +**Benefits**: +- Zero particles (massive performance win) +- Better visual quality (smooth falloff) +- Easy to control (density, color, distance) +- Works with any weather + +**When to use**: +- Fog weather type +- Distance-based visibility reduction +- Atmospheric depth cues + +**Real-world example**: Nearly all modern games use post-process fog, not particle-based. + + +## Common Pitfalls + +### Pitfall 1: Performance Death - Unbounded Particle Growth + +**Symptom**: FPS drops from 60 to single digits after a few minutes of rain. + +**Root Cause**: +```python +# WRONG: No particle limit +for _ in range(1000): # Spawn 1000 per frame! + particles.append(RainParticle()) +# After 60 frames: 60,000 particles → 1 FPS +``` + +**Why it happens**: Developers test for a few seconds, don't notice particles accumulating. + +**Fix**: Hard particle cap + object pooling +```python +# RIGHT: Enforce maximum +MAX_PARTICLES = 5000 + +if len(active_particles) < MAX_PARTICLES: + particle = particle_pool.get() # Reuse from pool + active_particles.append(particle) +``` + +**Testing**: Run game for 5+ minutes with heavy rain, monitor particle count. + +### Pitfall 2: Night Too Dark - Unplayable Visibility + +**Symptom**: Players complain they can't see anything at night, quit game. + +**Root Cause**: +```python +# WRONG: Realistic night (pitch black) +if is_night(): + ambient_light = 0.0 # Can't see ANYTHING +``` + +**Why it happens**: Developers prioritize realism over playability. + +**Fix**: Minimum ambient light for gameplay +```python +# RIGHT: "Moonlight" minimum +if is_night(): + ambient_light = 0.15 # Dim but playable + # Add blue tint to simulate moonlight + ambient_color = (0.2, 0.3, 0.5) +``` + +**Balance**: Night should feel atmospheric, not frustrating. + +**Alternative**: Provide torch/lantern that player must manage (fuel, battery). + +### Pitfall 3: Weather Too Random - No Predictability + +**Symptom**: Snow in summer, instant weather changes, feels chaotic. + +**Root Cause**: +```python +# WRONG: Completely random +weather = random.choice(['clear', 'rain', 'snow', 'fog']) +# Can jump from clear to blizzard instantly! +``` + +**Why it happens**: Randomness is easy to implement, patterns require system design. + +**Fix**: Use Markov chain with seasonal constraints +```python +# RIGHT: Pattern-based transitions +next_weather = markov_chain.transition(current_weather, current_season) +# Clear → Cloudy → Rain (gradual) +# No snow in summer (seasonal rules) +``` + +**Result**: Weather feels natural, players can anticipate changes. + +### Pitfall 4: Instant Weather Changes - Jarring Transitions + +**Symptom**: Weather switches instantly (clear → downpour in 1 frame). + +**Root Cause**: +```python +# WRONG: Instant switch +if should_change_weather(): + weather = new_weather # Instant! +``` + +**Fix**: Smooth transition over time +```python +# RIGHT: 5-second transition +if transitioning: + t += delta_time / transition_duration # 0 to 1 + intensity = lerp(old_intensity, new_intensity, t) +``` + +**Transition duration**: +- Clear → Cloudy: 10 seconds +- Cloudy → Rain: 5 seconds +- Rain → Clear: 15 seconds (gradual clearing) + +### Pitfall 5: Particle Update Cost - No Culling + +**Symptom**: Particles far from camera still tank FPS. + +**Root Cause**: +```python +# WRONG: Update ALL particles +for particle in all_particles: + particle.update() # Even if 500m away! +``` + +**Fix**: Distance-based culling +```python +# RIGHT: Only update visible particles +for particle in all_particles: + if distance(particle, camera) < fog_distance: + particle.update() + else: + # Cull distant particles + particle_pool.return(particle) +``` + +**Savings**: 50-70% of particle update cost. + +### Pitfall 6: No LOD - Same Density Everywhere + +**Symptom**: Performance poor even with particle cap. + +**Root Cause**: Same particle density near and far. + +**Fix**: LOD-based spawn +```python +# RIGHT: Reduce density with distance +if distance < 50: + spawn_count = 100 # Full density +elif distance < 150: + spawn_count = 50 # 50% density +elif distance < 300: + spawn_count = 25 # 25% density +``` + +**Result**: 2-3× better performance with same visual quality. + +### Pitfall 7: No Time Budget - Frame Spikes + +**Symptom**: Occasional frame drops (60 → 40 FPS) when spawning particles. + +**Root Cause**: Particle system takes too long some frames. + +**Fix**: Time budget with early exit +```python +# RIGHT: Enforce budget +start = time.perf_counter() +for particle in particles: + particle.update() + if (time.perf_counter() - start) > 0.002: # 2ms budget + break # Continue next frame +``` + +**Result**: Consistent frame pacing. + +### Pitfall 8: GC Pressure - Creating Objects Every Frame + +**Symptom**: Micro-stuttering, frame time spikes every few seconds. + +**Root Cause**: Garbage collector running constantly. + +**Fix**: Object pooling +```python +# WRONG: New objects every frame +particle = RainParticle() + +# RIGHT: Reuse from pool +particle = particle_pool.get_or_create() +``` + +**Impact**: Eliminates GC spikes. + +### Pitfall 9: No Gameplay Integration - Cosmetic Only + +**Symptom**: Weather looks nice but doesn't matter strategically. + +**Root Cause**: Weather is just visual, no mechanical effects. + +**Fix**: Weather modifies gameplay +```python +# RIGHT: Weather affects mechanics +player.movement_speed *= weather.get_movement_modifier() +enemy_detection_range *= weather.get_visibility_modifier() +footstep_audio_range *= (1 - weather.get_audio_masking()) +``` + +**Result**: Weather becomes tactical consideration. + +### Pitfall 10: No Seasonal System - Snow in Summer + +**Symptom**: Immersion broken by incorrect weather for season. + +**Root Cause**: Weather independent of season. + +**Fix**: Seasonal probability curves +```python +# RIGHT: Season determines weather chances +if season == SUMMER: + weather_chances = {'clear': 0.7, 'rain': 0.3, 'snow': 0.0} +elif season == WINTER: + weather_chances = {'clear': 0.3, 'rain': 0.0, 'snow': 0.7} +``` + + +## Real-World Examples + +### Example 1: Minecraft - Simple But Effective + +**Time System**: +- 20-minute day/night cycle (accelerated 72×) +- Day: 10 minutes, Night: 7 minutes, Twilight: 3 minutes +- Synchronized across multiplayer (server-authoritative) + +**Weather**: +- Simple binary: Clear or Rain +- Rain reduces sky brightness by 20% +- Rain extinguishes fire, fills cauldrons +- Thunder can strike and start fires + +**Performance**: +- Rain particles: ~500-1000 (very simple) +- Heavy LOD (particles only near player) +- No complex weather simulation + +**Key Insight**: Simplicity works if weather serves gameplay (rain fills cauldrons, enables fishing). + +### Example 2: Zelda: Breath of the Wild - Gameplay-First Weather + +**Time System**: +- Accelerated time (1 minute real = 1 hour game) +- Realistic sun path and shadows +- Time doesn't pass during cutscenes/menus + +**Weather**: +- Dynamic procedural weather +- **Gameplay effects**: + - Rain makes surfaces slippery (can't climb) + - Lightning targets metal equipment (must unequip) + - Cold/heat require appropriate clothing + - Updrafts form during certain weather (gliding) + +**Performance**: +- Particle budget: ~3,000 rain/snow particles +- Heavy LOD (75% reduction at 100m) +- Post-process fog (no particles) + +**Key Insight**: Weather creates gameplay challenges and opportunities, not just atmosphere. + +### Example 3: Animal Crossing - Real-Time Clock + +**Time System**: +- Real-time clock (1 second real = 1 second game) +- Synchronized to system time +- Shops open/close at specific hours + +**Weather**: +- Dynamic but gentle (no extreme weather) +- Seasonal variation (snow in December, rain in June) +- Weather affects villager behavior (stay indoors during rain) +- Meteors during clear nights + +**Key Insight**: Real-time creates daily routine, encourages checking in regularly. + +### Example 4: Red Dead Redemption 2 - Best-in-Class Transitions + +**Time System**: +- Realistic sun path with latitude consideration +- Dynamic length of day/night (longer days in summer) +- Accurate sunrise/sunset colors + +**Weather**: +- Extremely smooth transitions (5-15 minutes) +- Weather fronts visible in distance (see storm approaching) +- Regional weather (snow in mountains, clear in desert) +- Weather affects NPC behavior (seek shelter, change routes) + +**Performance**: +- Advanced particle LOD +- GPU-based particle simulation +- Temporal reprojection for particles + +**Key Insight**: Slow, smooth transitions make weather feel natural and immersive. + +### Example 5: Skyrim - Magic and Weather Interaction + +**Time System**: +- Accelerated time (1 minute real = 20 minutes game, configurable) +- "Wait" menu to skip time +- Time passes during fast travel + +**Weather**: +- Regional weather patterns (more snow in north) +- Weather affects spells: + - Lightning spells more powerful during thunderstorms + - Fire spells less effective in rain + - Frost spells more effective in snow +- Visibility reduced in fog/snow + +**Key Insight**: Weather can integrate with core mechanics (magic system). + +### Example 6: Don't Starve - Seasonal Survival + +**Seasons**: +- 16 days per season (64-day year) +- Spring: Rain, flooding, aggressive bees +- Summer: Drought, heat stroke, fires +- Fall: Mild, good for preparing +- Winter: Freezing, reduced food, hound attacks + +**Weather as Hazard**: +- Summer heat requires cooling +- Winter cold requires warming +- Rain reduces sanity, extinguishes fires +- Lightning can strike and kill player + +**Key Insight**: Seasons and weather are core survival challenges, not cosmetic. + + +## Cross-References + +### Within Bravos/Simulation-Tactics + +**Physics Simulation Patterns** → Weather and Time +- Particle physics for rain/snow +- Wind forces affecting particles + +**Spatial Partitioning** → Weather and Time +- Spatial grid for particle culling +- Region-based weather systems + +**Traffic and Pathfinding** → Weather and Time +- Weather affects pathfinding costs (slower in snow) +- NPC AI reacts to weather (seek shelter) + +### External Skillpacks + +**Yzmir/Performance-Optimization** +- Object pooling for particles +- Time budgets and profiling +- Cache-friendly particle updates + +**Axiom/Game-Engine-Patterns** +- Update loop integration +- Delta-time handling +- Time scaling and pause + +**Lyra/Game-Feel** +- Weather feedback (audio, visuals) +- Camera effects (rain on lens) +- Smooth transitions + +**Lyra/UX-Design** +- Time control UI +- Weather indicators +- Player communication of effects + + +## Testing Checklist + +Use this checklist to verify your weather and time system: + +### Performance + +- [ ] Particle count never exceeds budget (5000 max) +- [ ] FPS stays above 60 with maximum weather particles +- [ ] No frame time spikes (time budget enforced) +- [ ] No GC pressure (object pooling used) +- [ ] Distance culling removes particles beyond visibility +- [ ] LOD reduces particles by 75% at 300m + +### Gameplay Integration + +- [ ] Weather affects player movement speed +- [ ] Weather affects visibility range +- [ ] Weather affects audio masking +- [ ] AI reacts to weather (seeks shelter, changes behavior) +- [ ] Weather creates strategic opportunities (stealth in fog) +- [ ] Weather creates strategic challenges (slow movement in snow) + +### Visual Quality + +- [ ] Smooth twilight transitions (no instant darkness) +- [ ] Night is dim but playable (minimum 0.15 ambient light) +- [ ] Weather transitions gradually (5-15 seconds) +- [ ] Sun moves realistically (east to west) +- [ ] Sun color changes with elevation (orange at sunset) +- [ ] Fog uses post-process (not particles) + +### Time System + +- [ ] Time of day progresses smoothly +- [ ] Day/night cycle is noticeable to player +- [ ] Time can be accelerated (2x, 5x, 10x) +- [ ] Time can be paused (strategic pause) +- [ ] "Wait" mechanic skips boring periods +- [ ] Time synchronizes in multiplayer (if applicable) + +### Weather System + +- [ ] Weather has seasonal variation (no snow in summer) +- [ ] Weather transitions follow patterns (Clear → Cloudy → Rain) +- [ ] Weather durations feel right (not too rapid) +- [ ] Weather types are distinct (clear visual difference) +- [ ] Weather probabilities are tuned (not too much rain) +- [ ] Extreme weather is rare (blizzards uncommon) + +### Edge Cases + +- [ ] Midnight is playable (not pitch black) +- [ ] Heavy rain/blizzard doesn't crash game +- [ ] Time wraps correctly at 24 hours +- [ ] Seasons transition smoothly +- [ ] Weather is consistent across multiplayer clients +- [ ] Time acceleration doesn't break physics + +### User Experience + +- [ ] Weather changes are noticeable but not jarring +- [ ] Time control UI is accessible and clear +- [ ] "Wait" option is available when appropriate +- [ ] Weather effects are communicated to player +- [ ] Night is atmospheric but not frustrating +- [ ] Players can plan around weather + + +**End of Skill** + +This skill should enable you to build production-quality weather and time systems that: +1. Maintain 60 FPS with thousands of particles +2. Integrate weather into gameplay meaningfully +3. Balance realism with playability +4. Provide smooth, natural transitions +5. Give players control over time pacing +6. Create seasonal variety and patterns + +Apply these patterns, avoid these pitfalls, and test thoroughly using the checklist above.