Files
gh-tachyon-beep-skillpacks-…/skills/using-simulation-foundations/stability-analysis.md
2025-11-30 09:00:05 +08:00

2390 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#### Failure 1: Economy Hyperinflation (EVE Online Economy Collapse)
**Scenario**: Player-driven economy with ore mining, refining, and market trading. Designer wants balanced growth.
**What They Did**:
```python
# Per-minute resource changes, empirically tuned
ore_produced = num_miners * 50 * dt
ore_consumed = num_factories * 30 * dt
total_ore += ore_produced - ore_consumed
price = base_price * (supply / demand)
```
**What Went Wrong**:
- As player count grew from 100K to 500K, ore supply scaled linearly
- Ore demand grew sublinearly (factories/consumers didn't multiply as fast)
- Positive feedback: more ore → lower prices → more profitable mining → more miners
- After 6 months: ore prices dropped 85%, economy in freefall
- EVE devs had to manually spawn ISK sinks to prevent total collapse
- Investment in capitals became worthless overnight
**Why No One Predicted It**:
- No equilibrium analysis of production vs consumption
- Didn't check eigenvalues: all positive, system diverges
- Assumed "balancing by numbers" would work forever
- Player behavior (more mining when profitable) created unexpected feedback loop
**What Stability Analysis Would Have Shown**:
```
Production equation: dP/dt = α*N - β*P
where N = number of miners, P = ore price
Fixed point: P* = (α/β)*N
Jacobian: dP/dN = α/β > 0
Eigenvalue λ = α/β > 0 → UNSTABLE (diverges as N grows)
System will hyperinflate. Need negative feedback (diminishing returns, sink mechanisms).
```
#### Failure 2: Population Extinction Event (Rimworld Ecosystem Crash)
**Scenario**: Survival colony sim with herbivores (deer) eating plants, carnivores (wolves) hunting deer.
**What They Did**:
```python
# Lotka-Volterra predator-prey, empirically tuned
def update():
herbivores *= 1.0 + 0.1 * dt # 10% growth/minute
herbivores *= 1.0 - 0.001 * carnivores * dt # Predation
carnivores *= 1.0 + 0.05 * carnivores * herbivores * dt # Predation boost
carnivores *= 1.0 - 0.02 * dt # Starvation
```
**What Went Wrong**:
- Worked fine for 100 in-game days
- At day 150: sudden population collapse
- Herbivores died from overpredation
- Carnivores starved after 3 days
- Ecosystem went extinct in 10 minutes (in-game)
- Player's carefully-built colony plan destroyed
- No way to recover
**Why No One Predicted It**:
- No phase plane analysis of predator-prey dynamics
- Didn't check if limit cycle exists or if trajectories spiral inward
- Assumed tuned numbers would stay stable forever
- Didn't realize: small parameter changes can destroy cycles
**What Stability Analysis Would Have Shown**:
```
Lotka-Volterra system:
dH/dt = a*H - b*H*C
dC/dt = c*H*C - d*C
Equilibrium: H* = d/c, C* = a/b
Jacobian at equilibrium has purely imaginary eigenvalues
λ = ±i*√(ad) → NEUTRALLY STABLE (center)
System creates closed orbits (limit cycles)
Parameter tuning can:
- Move equilibrium point
- Shrink/expand limit cycle
- Turn center into spiral (convergent or divergent)
- NEED eigenvalue analysis to verify stability margin
```
#### Failure 3: Physics Engine Explosion (Ragdoll Simulation)
**Scenario**: Third-person game with ragdoll physics for NPC corpses.
**What They Did**:
```cpp
// Verlet integration with springs
Vec3 new_pos = 2*pos - old_pos + force/mass * dt*dt;
// Spring constraint: solve until stable
for(int i=0; i<5; i++) { // 5 iterations
Vec3 delta = target - pos;
pos += delta * 0.3f; // Spring stiffness
}
```
**What Went Wrong**:
- Works fine at 60fps
- At 144fps (high refresh rate): ragdolls vibrate uncontrollably
- At 240fps: corpses launch into the sky
- Streamer records clip: "NPC flew off map"
- Physics looks broken, game reviews drop
**Why No One Predicted It**:
- No stability analysis of time-stepping method
- Didn't compute critical timestep size
- Assumed iterative solver would always converge
- Framerate dependency not tested
**What Stability Analysis Would Have Shown**:
```
Verlet integration: x_{n+1} = 2x_n - x_{n-1} + a(dt)²
Stability region for damped harmonic oscillator: dt < 2/ω₀
where ω₀ = √(k/m) = natural frequency
For dt_max = 1/60s, ω₀ can be at most 120 rad/s
If you have ω₀ = 180 rad/s (stiff springs), system is UNSTABLE above 60fps
Solution: Use implicit integrator (Euler backwards) or reduce spring stiffness by analysis
```
#### Failure 4: Economy Oscillations Annoy Players (Game Economy Boom-Bust Cycle)
**Scenario**: Resource economy where player actions shift market dynamics. Price controls attempt to stabilize.
**What They Did**:
```python
# Price adjustment based on supply
demand = target_demand
supply = current_inventory
price_new = price + (demand - supply) * adjustment_factor
# Player behavior responds to price
if price > profitable_threshold:
more_players_farm_ore() # Increases supply
```
**What Went Wrong**:
- Quarter 1: High prices → players farm more ore
- Quarter 2: High ore supply → prices crash
- Quarter 3: Low prices → players stop farming
- Quarter 4: Low supply → prices spike again
- This 4-quarter boom-bust cycle repeats forever
- Players call it "economy is broken" and quit
- Timing of updates makes oscillations worse, not better
**Why No One Predicted It**:
- No limit cycle detection
- Didn't analyze feedback timing (players respond next quarter)
- Assumed static equilibrium exists and is stable
- Didn't realize: delayed feedback can create sustained oscillations
**What Stability Analysis Would Have Shown**:
```
Supply equation with delayed response:
dS/dt = k * (price(t-T) - profitable_threshold) - demand
Delay differential equation: solution oscillates if period > 2*T
Players respond with T = 1 quarter
Natural oscillation period ≈ 4 quarters
System creates sustained limit cycle
Fix: Need faster price adjustment OR player response (faster information)
OR add dampening mechanism (penalties for rapid farming)
```
#### Failure 5: AI Formation Explodes (RTS Unit Clustering)
**Scenario**: RTS game with units moving in formation. Flocking algorithm tries to keep units together.
**What They Did**:
```cpp
// Boid flocking with attraction to formation center
Vec3 cohesion_force = (formation_center - unit_pos) * 0.5f;
Vec3 separation_force = -get_nearby_units_repulsion();
Vec3 alignment_force = average_velocity_of_nearby_units * 0.2f;
unit_velocity += (cohesion_force + separation_force + alignment_force) * dt;
unit_pos += unit_velocity * dt;
```
**What Went Wrong**:
- Works for 10-unit squads
- At 100 units: units oscillate wildly in formation
- At 500 units: formation members pass through each other
- Separation forces break down at scale
- Infantry "glitches into" cavalry
- Players can exploit: run through enemy formation unharmed
**Why No One Predicted It**:
- No stability analysis of coupled oscillators (each unit influences others)
- Assumed forces would balance
- Didn't check eigenvalues of linearized system
- Never tested at scale (QA only tested 10-unit squads)
**What Stability Analysis Would Have Shown**:
```
100-unit system: 300-dimensional system of ODEs
Linearize around equilibrium (units in formation)
Jacobian matrix: 300x300, shows coupling strength between units
Eigenvalues λ_i indicate:
- Large positive λ → formation explodes (unstable)
- Negative λ with large |λ| → oscillations damp slowly
- Complex λ with small real part → sustained oscillation at formation
For 500 units, cohesion forces dominate → large positive eigenvalues
System is UNSTABLE, needs separation force tuning
Calculate: maximum cohesion coefficient before instability
κ_max = function(unit_count, separation_radius)
```
#### Failure 6: Difficulty AI Gets Stronger Forever (Left 4 Dead Director)
**Scenario**: Dynamic difficulty system adapts to player performance.
**What They Did**:
```python
# AI director learns and adapts
if player_score > target_score:
ai_strength += 0.05 # Get harder
else:
ai_strength -= 0.03 # Get easier
# AI buys better equipment
if ai_strength > 50:
equip_heavy_weapons()
```
**What Went Wrong**:
- First hour: perfectly tuned difficulty
- Hour 2: AI slowly gets stronger (asymmetric increase/decrease)
- Hour 4: AI is overpowered, impossible to win
- Players can't recover: AI keeps getting stronger
- Game becomes unplayable, players refund
**Why No One Predicted It**:
- No fixed-point analysis of adaptive system
- Assumed symmetry in increase/decrease would balance
- Didn't realize: +0.05 increase vs -0.03 decrease is asymmetric
- No equilibrium analysis of "when does AI strength stabilize?"
**What Stability Analysis Would Have Shown**:
```
AI strength dynamics:
dS/dt = +0.05 if score_player > target
dS/dt = -0.03 if score_player < target
Fixed point? Only at edges: S → 0 or S → max
No interior equilibrium means: system always drifts
Better model with negative feedback:
dS/dt = k * (score_player - target_score)
This has fixed point at: score_player = target_score
Stable if k < 0 (restorative force toward target)
Eigenvalue λ = k < 0 → stable convergence to target
Test with λ = -0.04 → converges in ~25 seconds
```
#### Failure 7: Reputation System Locks You Out (Social Game Reputation Spiral)
**Scenario**: Social game where reputation increases with positive actions, decreases with negative.
**What They Did**:
```python
# Simple reputation update
reputation += 1 if action == "good"
reputation -= 1 if action == "bad"
# Opportunities scale with reputation
good_opportunities = reputation * 10
bad_opportunities = (100 - reputation) * 10
```
**What Went Wrong**:
- Player starts at reputation 50
- Makes a few good choices: reputation → 70
- Now gets 700 good opportunities, very few bad ones
- Player almost always succeeds: reputation → 90
- Reaches reputation 95: only 50 good opportunities, 50 bad
- One mistake: reputation → 94
- Struggling to climb back: need 10 successes to recover 1 reputation lost
- Player feels "locked out" of lower difficulty
- Game becomes grinding nightmare
**Why No One Predicted It**:
- No bifurcation analysis of opportunity distribution
- Didn't see: fixed points at reputation 0 and 100 are attractors
- Didn't realize: middle region (50) is unstable
- Players get trapped in either "favored" or "cursed" state
**What Stability Analysis Would Have Shown**:
```
Reputation dynamics:
dR/dt = p_good(R) - p_bad(R)
where p_good(R) = 0.1*R, p_bad(R) = 0.1*(100-R)
Fixed points: dR/dt = 0 → R = 50
Stability at R=50:
dR/dR = 0.1 - (-0.1) = 0.2 > 0 → UNSTABLE (repulsive fixed point)
System diverges from R=50 toward R=0 or R=100 (stable boundaries)
This is called a "saddle point" in 1D
Fix: Need restoring force toward R=50
Add: dR/dt = -k*(R-50) + (player_action_effect)
This creates stable equilibrium at R=50 with damped approach
```
#### Failure 8: Healing Item Spam Breaks Economy (MMO Potion Economy)
**Scenario**: MMO where players consume healing potions. Crafters produce them.
**What They Did**:
```python
# Simple supply/demand model
potion_price = base_price + (demand - supply) * 10
# Crafters produce if profitable
if potion_price > craft_cost * 1.5:
crafters_producing += 10
else:
crafters_producing = max(0, crafters_producing - 20)
# Consumption scales with player count
consumption = player_count * 5 * dt
```
**What Went Wrong**:
- New expansion: player count 100K → 500K
- Consumption jumps 5x
- Prices spike (good for crafters)
- Crafters flood in to produce
- Supply exceeds consumption (overshooting)
- Prices crash to near-zero
- Crafters leave economy
- No one produces potions
- New players can't get potions
- Game becomes unplayable for non-crafters
**Why No One Predicted It**:
- No stability analysis of producer response
- Assumed simple supply/demand equilibrium
- Didn't model overshooting in producer count
- Delayed feedback from crafters (takes time to gear up)
**What Stability Analysis Would Have Shown**:
```
Supply/demand with producer adjustment:
dP/dt = demand - supply = D - α*n_crafters
dn/dt = β*(P - cost) - γ*n_crafters
Equilibrium: P* = cost, n* = D/α (number of crafters to meet demand)
Eigenvalues:
λ₁ = -β*α < 0 (stable)
λ₂ = -γ < 0 (stable)
BUT: If response time is very fast (large β), overshooting occurs
- Supply increases before demand signal registers
- Creates limit cycle or damped oscillation
Fix: Slower producer response (β smaller) or price prediction ahead of demand
```
#### Failure 9: Game Balance Shatters With One Patch (Fighting Game Patch Instability)
**Scenario**: Fighting game with 50 characters. Balance team adjusts damage values to tune metagame.
**What They Did**:
```python
# Character A was too weak, buff damage by 5%
damage_multiplier[A] *= 1.05
# This makes matchup A vs B very favorable for A
# Player picks A more, B gets weaker in meta
# Then they nerf B to compensate
damage_multiplier[B] *= 0.95
```
**What Went Wrong**:
- After 3 patches: game is wildly unbalanced
- Some characters 70% vs 30% winrate in matchups
- Nerfs to weak characters don't fix it (creates new imbalances)
- Community discovers one character breaks the game
- Pro scene dominated by 3 characters
- Casual players can't win with favorite character
- Game dies (see Street Fighter 6 balance complaints)
**Why No One Predicted It**:
- No dynamical systems analysis of matchup balance
- Didn't model how player picks affect meta
- Each patch treated independently (no stability verification)
- Didn't check: how do eigenvalues of balance change change?
**What Stability Analysis Would Have Shown**:
```
Character pick probability evolves by replicator dynamics:
dP_i/dt = P_i * (w_i - w_avg)
where w_i = average winrate of character i
Linearize around balanced state (all characters equal pick rate):
Jacobian matrix: 50x50 matrix of winrate sensitivities
Eigenvalues tell us:
- If all λ < 0: small imbalances self-correct (stable)
- If any λ > 0: imbalances grow (unstable)
- If λ ≈ 0: near-criticality (sensitive to parameter changes)
After each patch, check eigenvalues:
If max(λ) < -0.1 → stable balance
If max(λ) > -0.01 → fragile balance, one more patch breaks it
This predicts "one more nerf and the meta shatters"
```
#### Failure 10: Dwarf Fortress Abandonment Spiral (Fortress Collapse Cascade)
**Scenario**: Dwarf fortress colony with morale, food, and defense. Everything interconnected.
**What They Did**:
```python
# Morale affects work rate
work_efficiency = 1.0 + 0.1 * (morale - 50) / 50
# Morale drops with hunger
morale -= 2 if hungry else 0
# Hunger increases if not enough food
if food_supply < 10 * dwarf_count:
hunger_rate = 0.5
else:
hunger_rate = 0.0
# Defense drops if dwarves unhappy
defense = base_defense * work_efficiency
```
**What Went Wrong**:
- Fortress going well: 50 dwarves, everyone happy
- Trade caravan steals food (bug or intended?)
- Food supply drops below safety threshold
- Dwarves become hungry: morale drops
- Morale drops: work efficiency drops
- Work efficiency drops: farms aren't tended
- Farms fail: food supply crashes further
- Cascade into total collapse: fortress abandoned
- Player can't save it (all negative feedbacks)
**Why No One Predicted It**:
- No bifurcation analysis of interconnected systems
- Multiple feedback loops with different timescales
- Didn't identify "tipping point" where cascade becomes irreversible
- Patch tuning doesn't address underlying instability
**What Stability Analysis Would Have Shown**:
```
System of ODEs (simplified):
dM/dt = f(F) - g(M) [morale from food, decay]
dF/dt = h(E) - M/k [food production from efficiency, consumption]
dE/dt = E * (M - threshold) [efficiency from morale]
Equilibrium: M* = 50, F* = sufficient, E* = 1.0
Jacobian at equilibrium:
∂M/∂F > 0, ∂M/∂M < 0
∂F/∂E > 0, ∂F/∂M < 0
∂E/∂M > 0
Eigenvalues reveal:
One eigenvalue λ > 0 with large magnitude → UNSTABLE (diverges)
Initial perturbation gets amplified: cascade begins
This tipping point is predictable from matrix coefficients
Fix: Add damping or saturation to break positive feedback loops
dE/dt = E * min(M - threshold, 0) [can't collapse faster than k]
```
#### Failure 11: Asteroid Physics Simulation Crashes (N-Body Stability)
**Scenario**: Space game with asteroid field. Physics engine simulates 500 asteroids orbiting/colliding.
**What They Did**:
```cpp
// Runge-Kutta 4th order, dt = 1/60
for(auto& asteroid : asteroids) {
Vec3 a = gravity_acceleration(asteroid);
// RK4 integration
Vec3 k1 = a * dt;
Vec3 k2 = gravity_acceleration(asteroid + v*dt/2) * dt;
Vec3 k3 = gravity_acceleration(asteroid + v*dt/2) * dt;
Vec3 k4 = gravity_acceleration(asteroid + v*dt) * dt;
asteroid.pos += (k1 + 2*k2 + 2*k3 + k4) / 6;
}
```
**What Went Wrong**:
- Works fine at 60fps (dt = 1/60)
- Player moves asteroids with engine: perturbs orbits slightly
- After 5 minutes: asteroids are in different positions (drift)
- After 10 minutes: asteroids pass through each other
- After 15 minutes: physics explodes, asteroids launch into space
- Game becomes "gravity broken" meme on forums
**Why No One Predicted It**:
- No analysis of numerical stability
- RK4 is stable for smooth systems, not for stiff N-body systems
- Didn't compute characteristic timescale and compare to dt
- Long-term integrations require symplectic methods
**What Stability Analysis Would Have Shown**:
```
N-body problem is chaotic (Lyapunov exponent λ > 0)
Small perturbations grow exponentially: ||error|| ∝ e^(λt)
For asteroid-scale gravity: λ ≈ 0.001 per second
Error amplifies by factor e^1 ≈ 2.7 per 1000 seconds
After 600 seconds: initial error of 1cm becomes 3 meters
Standard RK4 error accumulates as O(dt^4) per step
After 10 minutes = 600 seconds = 36,000 steps:
Total error ≈ 36,000 * (1/60)^4 ≈ 16 meters
PLUS chaotic amplification: 2.7x → 43 meters
Solution: Use symplectic integrator (conserves energy exactly)
or use smaller dt (1/120 fps instead of 1/60)
or add error correction (scale velocities to conserve energy)
```
## GREEN Phase: Comprehensive Stability Analysis
### Section 1: Introduction to Equilibrium Points
**What is an equilibrium point?**
An equilibrium point is a state where the system doesn't change over time. If you start there, you stay there forever.
**Mathematical definition:**
```
For continuous system: dx/dt = f(x)
Equilibrium at x* means: f(x*) = 0
For discrete system: x_{n+1} = f(x_n)
Equilibrium at x* means: f(x*) = x*
```
**Game examples:**
1. **Health regeneration equilibrium**:
```python
# Continuous: dH/dt = k * (H_max - H)
# Equilibrium: dH/dt = 0 → H = H_max (always at full health if left alone)
# But in-combat: dH/dt = k * (H_max - H) - damage_rate
# If damage_rate = k * (H_combat - H_max), equilibrium at H_combat < H_max
# Player health stabilizes in combat, doesn't auto-heal to full
```
2. **Economy price equilibrium**:
```python
# Market clearing: dP/dt = supply_response(P) - demand(P)
# At equilibrium: supply(P*) = demand(P*)
# This is the "market clearing price"
# Example: ore market
# Supply: S(P) = 100*P (miners produce more at higher price)
# Demand: D(P) = 1000 - 10*P (buyers want less at higher price)
# Equilibrium: 100*P = 1000 - 10*P → P* = 9 gold per ore
```
3. **Population equilibrium (Lotka-Volterra)**:
```python
# dH/dt = a*H - b*H*C (herbivores grow, hunted by carnivores)
# dC/dt = c*H*C - d*C (carnivores grow from hunting, starve if no prey)
# Two equilibria:
# 1. Extinct: H=0, C=0 (if all die, none born)
# 2. Coexistence: H* = d/c, C* = a/b (specific populations that balance)
# Example: a=0.1, b=0.001, c=0.0001, d=0.05
# H* = 0.05 / 0.0001 = 500 herbivores
# C* = 0.1 / 0.001 = 100 carnivores
# "Natural equilibrium" for the ecosystem
```
**Finding equilibria programmatically:**
```python
import numpy as np
from scipy.optimize import fsolve
def ecosystem_dynamics(state, a, b, c, d):
H, C = state
dH = a*H - b*H*C
dC = c*H*C - d*C
return [dH, dC]
# Find equilibrium point(s)
# Start with guess: equal populations
guess = [500, 100]
equilibrium = fsolve(lambda x: ecosystem_dynamics(x, 0.1, 0.001, 0.0001, 0.05), guess)
print(f"Equilibrium: H={equilibrium[0]:.0f}, C={equilibrium[1]:.0f}")
# Output: Equilibrium: H=500, C=100
```
**Why equilibria matter for game design:**
- **Stable equilibrium** (attractor): System naturally drifts toward this state
- Player economy converges to "healthy state" over time
- Health regeneration settles to comfortable level
- **Design use**: Set prices/values at stable equilibria
- **Unstable equilibrium** (repeller): System naturally diverges from this state
- Population at unstable equilibrium will crash or explode
- Balance point that looks stable but isn't
- **Design risk**: Tuning around unstable point creates fragile balance
- **Saddle point** (partially stable): Stable in some directions, unstable in others
- "Balanced" reputation system but unstable overall
- Can reach it, but small push destabilizes it
- **Design risk**: Players get trapped or locked out
### Section 2: Linear Stability Analysis (Jacobian Method)
**Core idea: Stability determined by eigenvalues of Jacobian matrix**
When system is near equilibrium, linear analysis predicts behavior:
- Eigenvalue λ < 0 → state returns to equilibrium (stable)
- Eigenvalue λ > 0 → state diverges from equilibrium (unstable)
- Eigenvalue λ = 0 → inconclusive (nonlinear analysis needed)
- Complex eigenvalues λ = σ ± iω → oscillations with frequency ω, damping σ
**Mathematical setup:**
For system `dx/dt = f(x)`:
1. Find equilibrium: f(x*) = 0
2. Compute Jacobian matrix: J[i,j] = ∂f_i/∂x_j
3. Evaluate at equilibrium: J(x*)
4. Compute eigenvalues of J(x*)
5. Interpret stability
**Example: Predator-prey (Lotka-Volterra)**
```python
import numpy as np
def lotka_volterra_jacobian(H, C, a, b, c, d):
"""Compute Jacobian matrix of predator-prey system"""
J = np.array([
[a - b*C, -b*H], # ∂(dH/dt)/∂H, ∂(dH/dt)/∂C
[c*C, c*H - d] # ∂(dC/dt)/∂H, ∂(dC/dt)/∂C
])
return J
# Equilibrium point
a, b, c, d = 0.1, 0.001, 0.0001, 0.05
H_eq = d / c # 500
C_eq = a / b # 100
# Jacobian at equilibrium
J_eq = lotka_volterra_jacobian(H_eq, C_eq, a, b, c, d)
print("Jacobian at equilibrium:")
print(J_eq)
# Output:
# [[ 0. -0.5]
# [ 0.01 0. ]]
# Eigenvalues
eigenvalues = np.linalg.eigvals(J_eq)
print(f"Eigenvalues: {eigenvalues}")
# Output: Eigenvalues: [0.+0.07071068j -0.+0.07071068j]
# Pure imaginary! System oscillates, neither grows nor shrinks
# This is "center" - creates limit cycle
```
**Interpretation:**
- Eigenvalues: ±0.0707i (purely imaginary)
- Real part = 0: Neither exponentially growing nor decaying
- Imaginary part = 0.0707: Oscillation frequency ≈ 0.07 rad/time-unit
- **Stability**: System creates closed orbits (limit cycles)
- **Game implication**: Predator/prey populations naturally cycle!
**Example: Health regeneration in combat**
```python
def health_regen_jacobian(H, H_max, k, damage):
"""
System: dH/dt = k * (H_max - H) - damage
Equilibrium: H* = H_max - damage/k
Jacobian: J = -k (1D system)
"""
J = -k
return J
k = 0.1 # Regen rate
damage = 0.05 # Damage per second in combat
H_max = 100
H_eq = H_max - damage / k # 50 HP in combat
# Eigenvalue
eigenvalue = -k # -0.1
print(f"Eigenvalue: {eigenvalue}")
print(f"Stability: Stable, convergence timescale = 1/|λ| = {1/abs(eigenvalue):.1f} seconds")
# Player's health will converge to 50 HP in ~10 seconds of constant damage
# Above 50 HP: regen > damage (recover toward 50)
# Below 50 HP: damage > regen (drop toward 50)
```
**Interpretation:**
- Eigenvalue λ = -0.1
- **Stability**: Stable (negative)
- **Convergence time**: 1/|λ| = 10 seconds
- **Game design**: Player learns combat is winnable at health 50+
**Example: Economy with price feedback**
```python
def economy_jacobian(P, S_coeff, D_coeff):
"""
Supply: S(P) = S_coeff * P
Demand: D(P) = D_0 - D_coeff * P
Price dynamics: dP/dt = α * (D(P) - S(P))
At equilibrium: S(P*) = D(P*)
Jacobian: dP/dP = α * (dD/dP - dS/dP)
"""
alpha = 0.1 # Price adjustment speed
J = alpha * (-D_coeff - S_coeff)
return J
S_coeff = 100 # Miners produce 100 ore per gold of price
D_coeff = 10 # Buyers want 10 less ore per gold of price
J = economy_jacobian(None, S_coeff, D_coeff)
print(f"Jacobian element: {J}")
# Output: Jacobian element: -1.1
# Eigenvalue (1D system)
eigenvalue = J
print(f"Eigenvalue: {eigenvalue}")
print(f"Stability: Stable (negative)")
print(f"Convergence: Price settles in {1/abs(eigenvalue):.1f} seconds")
# Market clearing is STABLE - prices converge to equilibrium
# Deviation from equilibrium price corrects automatically
```
**Interpretation:**
- Eigenvalue λ = -1.1
- **Stability**: Stable
- **Convergence time**: ~0.9 seconds
- **Game design**: Price fluctuations resolve quickly
**When linear analysis works:**
✓ Small perturbations around equilibrium
✓ Smooth systems (continuous derivatives)
✓ Systems near criticality (eigenvalues ≈ 0)
**When linear analysis fails:**
✗ Far from equilibrium
✗ Systems with discontinuities
✗ Highly nonlinear (high-order interactions)
**Algorithm for linear stability analysis:**
```python
import numpy as np
from scipy.optimize import fsolve
def linear_stability_analysis(f, x0, epsilon=1e-6):
"""
Analyze stability of system dx/dt = f(x) near equilibrium x0.
Args:
f: Function f(x) that returns dx/dt as numpy array
x0: Initial guess for equilibrium point
epsilon: Finite difference step for Jacobian
Returns:
equilibrium: Equilibrium point
eigenvalues: Complex eigenvalues
stability: "stable", "unstable", "center", "saddle"
"""
# Step 1: Find equilibrium
def equilibrium_eq(x):
return f(x)
x_eq = fsolve(equilibrium_eq, x0)
# Step 2: Compute Jacobian by finite differences
n = len(x_eq)
J = np.zeros((n, n))
for i in range(n):
x_plus = x_eq.copy()
x_plus[i] += epsilon
f_plus = f(x_plus)
x_minus = x_eq.copy()
x_minus[i] -= epsilon
f_minus = f(x_minus)
J[:, i] = (f_plus - f_minus) / (2 * epsilon)
# Step 3: Compute eigenvalues
evals = np.linalg.eigvals(J)
# Step 4: Classify stability
real_parts = np.real(evals)
if all(r < -1e-6 for r in real_parts):
stability = "stable (all eigenvalues negative)"
elif any(r > 1e-6 for r in real_parts):
stability = "unstable (at least one eigenvalue positive)"
elif any(abs(r) < 1e-6 for r in real_parts):
stability = "center or neutral (eigenvalue near zero)"
return x_eq, evals, stability
```
### Section 3: Lyapunov Stability (Energy Methods)
**Core idea: Track energy-like function instead of computing Jacobians**
Lyapunov methods work when:
- System is far from equilibrium (nonlinear analysis)
- Jacobian analysis is inconclusive or complex
- You have intuition about "energy" or "potential"
**Definition: Lyapunov function V(x)**
A function V is a Lyapunov function if:
1. V(x*) = 0 (minimum at equilibrium)
2. V(x) > 0 for all x ≠ x* (positive everywhere else)
3. dV/dt < 0 along trajectories (energy decreases over time)
If all three conditions hold, equilibrium is **globally stable**.
**Example: Damped pendulum**
```python
import numpy as np
import matplotlib.pyplot as plt
# System: d²θ/dt² = -g/L * sin(θ) - b * dθ/dt
# In state form: dθ/dt = ω, dω/dt = -g/L * sin(θ) - b * ω
g, L, b = 9.8, 1.0, 0.5
# Lyapunov function: mechanical energy
# V = (1/2)*m*L²*ω² + m*g*L*(1 - cos(θ))
# Kinetic energy + gravitational potential energy
def lyapunov_function(theta, omega):
"""Mechanical energy (up to constants)"""
V = 0.5 * omega**2 + (g/L) * (1 - np.cos(theta))
return V
# Verify: dV/dt should be negative (damping dissipates energy)
def dV_dt(theta, omega, b=0.5):
"""
dV/dt = dV/dθ * dθ/dt + dV/dω * dω/dt
= sin(θ) * ω + ω * (-g/L * sin(θ) - b*ω)
= ω*sin(θ) - ω*g/L*sin(θ) - b*ω²
= -b*ω² ← negative!
"""
return -b * omega**2
# Simulate trajectory
dt = 0.01
theta, omega = np.pi * 0.9, 0.0 # Start near inverted position
time, theta_traj, omega_traj, V_traj = [], [], [], []
for t in range(1000):
# Store trajectory
time.append(t * dt)
theta_traj.append(theta)
omega_traj.append(omega)
V_traj.append(lyapunov_function(theta, omega))
# Step forward (Euler method)
dtheta = omega
domega = -(g/L) * np.sin(theta) - b * omega
theta += dtheta * dt
omega += domega * dt
plt.figure(figsize=(12, 4))
plt.subplot(131)
plt.plot(time, theta_traj)
plt.xlabel('Time')
plt.ylabel('Angle θ (rad)')
plt.title('Pendulum Angle')
plt.subplot(132)
plt.plot(time, omega_traj)
plt.xlabel('Time')
plt.ylabel('Angular velocity ω')
plt.title('Pendulum Angular Velocity')
plt.subplot(133)
plt.plot(time, V_traj)
plt.xlabel('Time')
plt.ylabel('Lyapunov function V')
plt.title('Energy Decreases Over Time')
plt.yscale('log')
plt.tight_layout()
plt.show()
# Energy decays exponentially → stable convergence to θ=0, ω=0
```
**Interpretation:**
- V(θ=0, ω=0) = 0 (minimum)
- V > 0 everywhere else
- dV/dt = -b*ω² < 0 (energy decreases)
- **Conclusion**: Pendulum returns to resting position (globally stable)
**Game example: Character resource depletion**
```python
# Mana system with regeneration
# dM/dt = regen_rate * (1 - M/M_max) - casting_cost
# Lyapunov function: "distance from comfortable level"
# V = (M - M_comfortable)²
# dV/dt = 2*(M - M_comfortable) * dM/dt
# If regen restores toward M_comfortable: dV/dt < 0
# So character's mana stabilizes at M_comfortable
M_max = 100
M_comfortable = 60
regen_rate = 10 # Per second
casting_cost = 5 # Per cast per second
def mana_dynamics(M):
dM = regen_rate * (1 - M/M_max) - casting_cost
return dM
# Check stability
M_eq = M_comfortable
dM_eq = mana_dynamics(M_eq)
print(f"At M={M_eq}: dM/dt = {dM_eq}")
# If dM_eq ≈ 0: equilibrium point
# Adjust regen_rate so that dM_eq = 0 at M_comfortable
regen_rate_needed = casting_cost / (1 - M_comfortable/M_max)
print(f"Regen rate needed: {regen_rate_needed:.1f}")
# Output: Regen rate needed: 50.0
# With regen_rate = 50:
# dM/dt = 50 * (1 - M/100) - 5 = 0 when M = 90
# So equilibrium is at 90 mana, not 60!
# Adjust desired equilibrium
M_desired = 70
regen_rate = casting_cost / (1 - M_desired/M_max)
# dM/dt = regen_rate * (1 - 70/100) - 5
# = regen_rate * 0.3 - 5 = 0
# → regen_rate = 16.67
```
**Using Lyapunov for nonlinear stability:**
```python
def is_lyapunov_stable(f, V, grad_V, x0, N_samples=1000, dt=0.01):
"""
Check if V is a valid Lyapunov function for system dx/dt = f(x).
Returns True if V(x) > 0 for all x ≠ x0 and dV/dt < 0 everywhere.
"""
# Generate random perturbations
np.random.seed(42)
errors = []
for trial in range(N_samples):
# Random perturbation
x = x0 + np.random.randn(len(x0)) * 0.1
# Check V(x) > 0
V_x = V(x)
if V_x <= 0 and not np.allclose(x, x0):
errors.append(f"V(x) = {V_x} ≤ 0 at x = {x}")
# Check dV/dt < 0
dx = f(x)
grad = grad_V(x)
dV = np.dot(grad, dx)
if dV >= 0:
errors.append(f"dV/dt = {dV} ≥ 0 at x = {x}")
if errors:
print(f"Lyapunov function FAILED {len(errors)} checks:")
for e in errors[:5]:
print(f" {e}")
return False
else:
print(f"Lyapunov function VALID (passed {N_samples} random tests)")
return True
```
### Section 4: Limit Cycles and Bifurcations
**Limit cycles: Periodic orbits that systems spiral toward**
Unlike equilibrium points (single fixed state), limit cycles are closed orbits where:
- System returns to same state after periodic time T
- Nearby trajectories spiral onto the cycle
- System oscillates forever with constant amplitude
**Example: Van der Pol oscillator (game economy)**
```python
# Van der Pol: d²x/dt² + μ(x² - 1)dx/dt + x = 0
# In state form: dx/dt = y, dy/dt = -x - μ(x² - 1)y
# Game analog: population with birth/death feedback
# dP/dt = (1 - (P/P_sat)²) * P - hunting
import numpy as np
from scipy.integrate import odeint
import matplotlib.pyplot as plt
def van_der_pol(state, t, mu):
x, y = state
dx = y
dy = -x - mu * (x**2 - 1) * y
return [dx, dy]
# Simulate different initial conditions
t = np.linspace(0, 50, 5000)
mu = 0.5 # Nonlinearity parameter
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Phase space plot
ax = axes[0]
colors = plt.cm.viridis(np.linspace(0, 1, 5))
for i, init_cond in enumerate([
[0.1, 0],
[2, 0],
[5, 0],
[-2, 1],
[0, 3]
]):
solution = odeint(van_der_pol, init_cond, t, args=(mu,))
ax.plot(solution[:, 0], solution[:, 1], color=colors[i], label=f'init {i+1}')
ax.set_xlabel('x (position/population)')
ax.set_ylabel('y (velocity/birth-death rate)')
ax.set_title(f'Van der Pol Phase Space (μ={mu})')
ax.grid(True)
ax.legend()
# Time series
ax = axes[1]
solution = odeint(van_der_pol, [0.1, 0], t, args=(mu,))
ax.plot(t, solution[:, 0], label='Position')
ax.plot(t, solution[:, 1], label='Velocity')
ax.set_xlabel('Time')
ax.set_ylabel('State value')
ax.set_title('Time Series Evolution')
ax.legend()
plt.tight_layout()
plt.show()
# All trajectories spiral toward the same limit cycle
# This cycle has period T ≈ 6.6 time units
# Amplitude oscillates between x ≈ -2 and x ≈ +2
```
**Game interpretation:**
- Population spirals toward stable oscillation
- Population naturally cycles (boom → bust → boom)
- Amplitude is predictable from μ parameter
- **Design decision**: Is this cycling good or bad?
**Bifurcations: When limit cycles are born or die**
A bifurcation is a critical parameter value where system behavior changes qualitatively.
**Hopf bifurcation: From equilibrium to limit cycle**
```python
# System: dx/dt = μ*x - ω*y - x(x² + y²)
# dy/dt = ω*x + μ*y - y(x² + y²)
# At μ = 0: Stable equilibrium at (0,0)
# For μ > 0: Limit cycle of radius √μ appears!
# For μ < 0: Even more stable equilibrium
def hopf_bifurcation_system(state, mu, omega):
x, y = state
r_squared = x**2 + y**2
dx = mu*x - omega*y - x*r_squared
dy = omega*x + mu*y - y*r_squared
return [dx, dy]
# Plot bifurcation diagram: amplitude vs parameter
mu_values = np.linspace(-0.5, 1.0, 100)
amplitudes = []
for mu in mu_values:
if mu <= 0:
amplitudes.append(0) # Only equilibrium point
else:
amplitudes.append(np.sqrt(mu)) # Limit cycle radius
plt.figure(figsize=(10, 6))
plt.plot(mu_values, amplitudes, linewidth=2)
plt.axvline(x=0, color='r', linestyle='--', label='Bifurcation point')
plt.xlabel('Parameter μ')
plt.ylabel('Oscillation Amplitude')
plt.title('Hopf Bifurcation: Birth of Limit Cycle')
plt.grid(True)
plt.legend()
plt.show()
# Game implication:
# μ = 0 is the critical point
# For μ slightly < 0: System stable, no oscillations
# For μ slightly > 0: System oscillates with amplitude √μ
# Players will notice sudden change in behavior!
```
**Period-doubling cascade: Route to chaos**
```python
# Logistic map: x_{n+1} = r * x_n * (1 - x_n)
# Simulates population growth with competition
def logistic_map_bifurcation():
"""Compute period-doubling route to chaos"""
r_values = np.linspace(2.8, 4.0, 2000)
periods = []
amplitudes = []
for r in r_values:
x = 0.1 # Initial condition
# Transient: discard first 1000 iterations
for _ in range(1000):
x = r * x * (1 - x)
# Collect steady-state values
steady_state = []
for _ in range(200):
x = r * x * (1 - x)
steady_state.append(x)
amplitudes.append(np.std(steady_state))
plt.figure(figsize=(12, 6))
plt.plot(r_values, amplitudes, ',k', markersize=0.5)
plt.xlabel('Parameter r (growth rate)')
plt.ylabel('Population oscillation amplitude')
plt.title('Period-Doubling Bifurcation Cascade')
plt.axvline(x=3.0, color='r', linestyle='--', alpha=0.5, label='Period 2')
plt.axvline(x=3.57, color='orange', linestyle='--', alpha=0.5, label='Chaos')
plt.legend()
plt.grid(True)
plt.show()
# Game implications:
# r ≈ 2.8: Stable population
# r ≈ 3.0: Population oscillates (period 2)
# r ≈ 3.45: Population oscillates with period 4
# r > 3.57: Chaotic population (unpredictable)
# Small change in r can cause dramatic behavior shift!
logistic_map_bifurcation()
```
**Period-doubling in action (game economy example):**
```python
# Simplified economy: producer response with delay
# Supply_{n+1} = β * price_n + (1-β) * Supply_n
# Price_{n+1} = (Demand - Supply_{n+1}) * sensitivity
import matplotlib.pyplot as plt
def economy_period_doubling():
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
for idx, beta in enumerate([0.3, 0.5, 0.7, 0.9]):
ax = axes[idx // 2, idx % 2]
supply = 100
demand = 100
price = 10
time_steps = 200
prices = []
for t in range(time_steps):
prices.append(price)
# Producer response (delayed by one step)
supply = beta * price * 10 + (1 - beta) * supply
# Price adjustment
price_error = (demand - supply) / demand
price = price * (1 + 0.1 * price_error)
# Keep price in reasonable range
price = max(0.1, min(price, 50))
ax.plot(prices[50:], 'b-', linewidth=1)
ax.set_title(f'Producer Response Speed β={beta}')
ax.set_xlabel('Time')
ax.set_ylabel('Price')
ax.grid(True)
# Detect period
if abs(prices[-1] - prices[-2]) < 0.01:
period = 1
elif abs(prices[-1] - prices[-3]) < 0.01:
period = 2
else:
period = "Complex"
ax.text(0.5, 0.95, f'Period: {period}',
transform=ax.transAxes,
ha='center', va='top',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
plt.tight_layout()
plt.show()
# As β increases:
# β=0.3: Stable price (convergence)
# β=0.5: Price oscillates with period 2
# β=0.7: Period-doubling bifurcations appear
# β=0.9: Chaotic price fluctuations
economy_period_doubling()
```
### Section 5: Practical Workflow for Stability Testing
**Step-by-step process for analyzing your game system:**
**1. Model the system**
Write down differential equations or discrete update rules:
```python
# Example: Character health system in-combat
class HealthModel:
def __init__(self, H_max=100, regen_rate=5, damage_rate=10):
self.H_max = H_max
self.regen_rate = regen_rate
self.damage_rate = damage_rate
def dynamics(self, H):
"""dH/dt = regen - damage"""
dH = self.regen_rate * (1 - H/self.H_max) - self.damage_rate
return dH
def equilibrium(self):
"""Find where dH/dt = 0"""
# regen_rate * (1 - H/H_max) = damage_rate
# 1 - H/H_max = damage_rate / regen_rate
# H = H_max * (1 - damage_rate/regen_rate)
H_eq = self.H_max * (1 - self.damage_rate/self.regen_rate)
return max(0, min(self.H_max, H_eq))
health_system = HealthModel()
H_eq = health_system.equilibrium()
print(f"Equilibrium health: {H_eq} / 100")
# Output: Equilibrium health: 50.0 / 100
```
**2. Find equilibria**
Solve f(x*) = 0 for continuous systems or f(x*) = x* for discrete:
```python
from scipy.optimize import fsolve
# For continuous system
def health_system_f(H):
regen_rate = 5
H_max = 100
damage_rate = 10
return regen_rate * (1 - H/H_max) - damage_rate
H_eq = fsolve(health_system_f, 50)[0]
print(f"Equilibrium (numerical): {H_eq:.1f}")
# Verify it's actually an equilibrium
print(f"f(H_eq) = {health_system_f(H_eq):.6f}") # Should be ≈ 0
```
**3. Compute Jacobian and eigenvalues**
For linear stability:
```python
def health_jacobian_derivative(H, regen_rate=5, H_max=100):
"""dH/dH = -regen_rate/H_max"""
return -regen_rate / H_max
H_eq = 50
eigenvalue = health_jacobian_derivative(H_eq)
print(f"Eigenvalue: λ = {eigenvalue}")
print(f"Stability: ", end="")
if eigenvalue < 0:
print(f"STABLE (return time = {1/abs(eigenvalue):.1f} seconds)")
elif eigenvalue > 0:
print(f"UNSTABLE (divergence rate = {eigenvalue:.3f}/sec)")
else:
print(f"MARGINAL (needs nonlinear analysis)")
# Output: Eigenvalue: λ = -0.05
# Stability: STABLE (return time = 20.0 seconds)
```
**4. Test stability numerically**
Simulate system and check if small perturbations grow or shrink:
```python
def simulate_health_perturbed(H0=40, duration=100, dt=0.01):
"""
Simulate health recovery from below equilibrium.
If it converges to 50, equilibrium is stable.
"""
H = H0
time = np.arange(0, duration, dt)
trajectory = []
regen_rate = 5
H_max = 100
damage_rate = 10
for t in time:
trajectory.append(H)
# Euler step
dH = regen_rate * (1 - H/H_max) - damage_rate
H += dH * dt
return time, trajectory
# Test 1: Start below equilibrium
time, traj = simulate_health_perturbed(H0=30)
print(f"Starting at 30 HP: converges to {traj[-1]:.1f} HP ✓")
# Test 2: Start above equilibrium
time, traj = simulate_health_perturbed(H0=70)
print(f"Starting at 70 HP: converges to {traj[-1]:.1f} HP ✓")
# Both converge to same point → stable equilibrium
```
**5. Check robustness to parameter changes**
Make sure equilibrium stability doesn't vanish with small tuning:
```python
def stability_vs_regen_rate():
"""
As regen rate changes, does equilibrium stability change?
"""
regen_rates = np.linspace(1, 15, 50)
eigenvalues = []
equilibria = []
H_max = 100
damage_rate = 10
for regen in regen_rates:
# Equilibrium
H_eq = H_max * (1 - damage_rate/regen)
equilibria.append(H_eq)
# Eigenvalue
eig = -regen / H_max
eigenvalues.append(eig)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
# Equilibrium vs regen rate
ax1.plot(regen_rates, equilibria)
ax1.axhline(y=0, color='r', linestyle='--', alpha=0.5)
ax1.axhline(y=100, color='r', linestyle='--', alpha=0.5)
ax1.set_xlabel('Regen rate (HP/sec)')
ax1.set_ylabel('Equilibrium health (HP)')
ax1.set_title('Equilibrium vs Parameter')
ax1.grid(True)
# Eigenvalue vs regen rate
ax2.plot(regen_rates, eigenvalues)
ax2.axhline(y=0, color='r', linestyle='--', alpha=0.5)
ax2.fill_between(regen_rates, -np.inf, 0, alpha=0.1, color='green', label='Stable')
ax2.fill_between(regen_rates, 0, np.inf, alpha=0.1, color='red', label='Unstable')
ax2.set_xlabel('Regen rate (HP/sec)')
ax2.set_ylabel('Eigenvalue λ')
ax2.set_title('Stability vs Parameter')
ax2.legend()
ax2.grid(True)
plt.tight_layout()
plt.show()
print("Conclusion: Eigenvalue is ALWAYS negative")
print("→ Equilibrium is stable for ALL reasonable regen rates")
print("→ Health system is robust to tuning")
stability_vs_regen_rate()
```
### Section 6: Implementation Patterns
**Pattern 1: Testing stability before shipping**
```python
class GameSystem:
"""Base class for game systems with automatic stability checking"""
def __init__(self, state, dt=0.016):
self.state = state
self.dt = dt
def dynamics(self, state):
"""Override in subclass: return dx/dt"""
raise NotImplementedError
def find_equilibrium(self, x0):
"""Find equilibrium point"""
from scipy.optimize import fsolve
eq = fsolve(self.dynamics, x0)
return eq
def compute_jacobian(self, x, epsilon=1e-6):
"""Numerical Jacobian"""
n = len(x)
J = np.zeros((n, n))
f_x = self.dynamics(x)
for i in range(n):
x_plus = x.copy()
x_plus[i] += epsilon
f_plus = self.dynamics(x_plus)
J[:, i] = (f_plus - f_x) / epsilon
return J
def analyze_stability(self, x_eq, epsilon=1e-6):
"""Analyze stability at equilibrium"""
J = self.compute_jacobian(x_eq, epsilon)
evals = np.linalg.eigvals(J)
max_real = np.max(np.real(evals))
if max_real < -1e-6:
stability = "STABLE"
elif max_real > 1e-6:
stability = "UNSTABLE"
else:
stability = "MARGINAL"
return evals, stability
def test_stability(self, x_eq, perturbation_size=0.01):
"""
Test stability numerically: apply small perturbation,
see if it returns to equilibrium.
"""
x = x_eq + perturbation_size * np.random.randn(len(x_eq))
distances = []
for step in range(1000):
distances.append(np.linalg.norm(x - x_eq))
# Simulate one step
dx = self.dynamics(x)
x = x + dx * self.dt
# Check if distance decreases
early_dist = np.mean(distances[:100])
late_dist = np.mean(distances[900:])
is_stable = late_dist < early_dist
return distances, is_stable
# Example: Economy system
class EconomySystem(GameSystem):
def dynamics(self, state):
price = state[0]
supply = 100 * price # Miners produce more at high price
demand = 1000 - 10 * price # Buyers want less at high price
dp = 0.1 * (demand - supply) # Price adjustment
return np.array([dp])
economy = EconomySystem(np.array([9.0])) # Start near equilibrium
# Equilibrium should be at price = 9
x_eq = economy.find_equilibrium(np.array([9.0]))
print(f"Equilibrium price: {x_eq[0]:.2f} gold")
# Check stability
evals, stability = economy.analyze_stability(x_eq)
print(f"Eigenvalue: {evals[0]:.3f}")
print(f"Stability: {stability}")
# Numerical test
distances, is_stable = economy.test_stability(x_eq)
print(f"Numerical test: {'STABLE' if is_stable else 'UNSTABLE'}")
```
**Pattern 2: Detecting bifurcations in production**
```python
def detect_bifurcation(system, param_name, param_range, state_eq):
"""
Scan a parameter range and detect bifurcations.
Bifurcations appear where equilibrium stability changes.
"""
param_values = np.linspace(param_range[0], param_range[1], 100)
max_eigenvalues = []
equilibria = []
for param_val in param_values:
# Set parameter
setattr(system, param_name, param_val)
# Find equilibrium
x_eq = system.find_equilibrium(state_eq)
equilibria.append(x_eq)
# Stability
J = system.compute_jacobian(x_eq)
evals = np.linalg.eigvals(J)
max_eig = np.max(np.real(evals))
max_eigenvalues.append(max_eig)
# Find bifurcation points
crossings = np.where(np.diff(np.sign(max_eigenvalues)))[0]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
ax1.plot(param_values, max_eigenvalues, linewidth=2)
ax1.axhline(y=0, color='r', linestyle='--', label='Stability boundary')
for crossing in crossings:
ax1.axvline(x=param_values[crossing], color='orange', linestyle=':', alpha=0.5)
ax1.fill_between(param_values, -np.inf, 0, alpha=0.1, color='green', label='Stable')
ax1.fill_between(param_values, 0, np.inf, alpha=0.1, color='red', label='Unstable')
ax1.set_xlabel(f'Parameter {param_name}')
ax1.set_ylabel('Max Eigenvalue')
ax1.set_title('Stability vs Parameter')
ax1.legend()
ax1.grid(True)
ax2.plot(param_values, np.array(equilibria)[:, 0])
for crossing in crossings:
ax2.axvline(x=param_values[crossing], color='orange', linestyle=':', alpha=0.5)
ax2.set_xlabel(f'Parameter {param_name}')
ax2.set_ylabel('Equilibrium Value')
ax2.set_title('Equilibrium vs Parameter')
ax2.grid(True)
plt.tight_layout()
plt.show()
if crossings.size > 0:
print(f"⚠️ BIFURCATION DETECTED at parameter values:")
for c in crossings:
print(f" {param_name}{param_values[c]:.3f} " +
f"(eigenvalue changes from {max_eigenvalues[c]:.3f} to {max_eigenvalues[c+1]:.3f})")
return True
else:
print(f"✓ No bifurcations in range [{param_range[0]}, {param_range[1]}]")
return False
```
### Section 7: Decision Framework
**When to use stability analysis:**
**Economy systems** - Prevent hyperinflation and crashes
**Population dynamics** - Predict extinction or explosion
**Physics systems** - Ensure numerical stability
**Difficulty scaling** - Avoid AI that grows uncontrollably
**Feedback loops** - Understand cascading failures
**Parameter tuning** - Know which parameters are critical
**Reproducibility** - Verify system doesn't chaotically diverge
**When NOT to use stability analysis:**
**Simple systems** - One or two variables
**Linear systems** - Already stable by default
**Stochastic systems** - Randomness dominates
**Tight time budgets** - Analysis takes hours
**Early prototypes** - Analysis too early
**Purely numerical problems** - No feedback loops
**How to choose method:**
| Problem | Method | Why |
|---------|--------|-----|
| Fixed point stable? | Eigenvalues | Fast, exact for linear |
| Far from equilibrium? | Lyapunov | Works globally |
| System oscillating? | Limit cycle detection | Find periodic behavior |
| Parameter sensitivity? | Bifurcation analysis | Identify tipping points |
| Chaotic behavior? | Lyapunov exponents | Measure exponential growth |
| Multiple equilibria? | Phase plane analysis | Visualize basins |
### Section 8: Testing Checklist
Before shipping, verify:
- [ ] **All equilibria found** - Use numerical methods to find ALL fixed points
- [ ] **Stability classified** - Each equilibrium is stable/unstable/saddle
- [ ] **Perturbation tested** - Small perturbations return to/diverge from equilibrium
- [ ] **Parameter range checked** - Stability holds over reasonable parameter range
- [ ] **Bifurcations located** - Critical parameters where behavior changes
- [ ] **Limit cycles detected** - If system oscillates, characterize amplitude/period
- [ ] **Eigenvalues safe** - No eigenvalues near criticality (|λ| > 0.1)
- [ ] **Long-term simulation** - Run 10x longer than gameplay duration, check divergence
- [ ] **Numerical method stable** - Test at high framerate, verify no explosion
- [ ] **Edge cases handled** - What happens at boundaries? (x=0, x=max, x<0 illegal?)
- [ ] **Player behavior** - Model how players respond, re-analyze with that feedback
- [ ] **Comparative testing** - Old vs new balance patch, check eigenvalue changes
## REFACTOR Phase: 6 Pressure Tests
### Test 1: Rimworld Ecosystem Stability
**Setup**: Colony with herbivores (deer, alpacas), carnivores (wolves, bears), food production.
**Parameters to tune:**
- Herbivore birth/death rates
- Carnivore hunting efficiency
- Predator metabolic rate
- Plant growth rate
**Stability checks:**
```python
import numpy as np
from scipy.integrate import odeint
# Rimworld-style ecosystem
def rimworld_ecosystem(state, t, params):
deer, wolves, plants, colonists = state
a_deer = params['deer_birth']
b_predation = params['predation_rate']
c_hunt_efficiency = params['hunt_efficiency']
d_wolf_death = params['wolf_death']
e_plant_growth = params['plant_growth']
dDeer = a_deer * deer - b_predation * deer * wolves
dWolves = c_hunt_efficiency * deer * wolves - d_wolf_death * wolves
dPlants = e_plant_growth * (1 - deer/1000) - 0.1 * deer
dColonists = 0 # Static for now
return [dDeer, dWolves, dPlants, dColonists]
# Find equilibrium
def find_rimworld_equilibrium():
from scipy.optimize import fsolve
params = {
'deer_birth': 0.1,
'predation_rate': 0.001,
'hunt_efficiency': 0.0001,
'wolf_death': 0.05,
'plant_growth': 0.3
}
def equilibrium_eq(state):
return rimworld_ecosystem(state, 0, params)
# Guess: balanced ecosystem
x_eq = fsolve(equilibrium_eq, [500, 100, 5000, 10])
return x_eq, params
# Stability test
x_eq, params = find_rimworld_equilibrium()
print(f"Equilibrium: Deer={x_eq[0]:.0f}, Wolves={x_eq[1]:.0f}, Plants={x_eq[2]:.0f}")
# Simulate for 5000 days (in-game time)
t = np.linspace(0, 5000, 10000)
solution = odeint(rimworld_ecosystem, x_eq + np.array([50, 10, 500, 0]), t, args=(params,))
# Check stability
final_state = solution[-1]
distance_from_eq = np.linalg.norm(final_state - x_eq)
initial_distance = np.linalg.norm(solution[0] - x_eq)
if distance_from_eq < initial_distance:
print("✓ Ecosystem is STABLE - populations converge to equilibrium")
else:
print("✗ Ecosystem is UNSTABLE - populations diverge from equilibrium")
# Plot phase space
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 6))
plt.plot(t, solution[:, 0], label='Deer', linewidth=1)
plt.plot(t, solution[:, 1], label='Wolves', linewidth=1)
plt.plot(t, solution[:, 2], label='Plants', linewidth=1)
plt.axhline(y=x_eq[0], color='C0', linestyle='--', alpha=0.3)
plt.axhline(y=x_eq[1], color='C1', linestyle='--', alpha=0.3)
plt.axhline(y=x_eq[2], color='C2', linestyle='--', alpha=0.3)
plt.xlabel('In-game days')
plt.ylabel('Population')
plt.legend()
plt.title('Rimworld Ecosystem Over 5000 Days')
plt.grid(True)
plt.show()
```
**Stability requirements:**
- ✓ Populations converge to equilibrium within 1000 days
- ✓ Small perturbations (e.g., player kills 10 wolves) don't cause collapse
- ✓ Ecosystem handles seasonal variations (plant growth varies)
- ✓ Extinction events don't cascade (if wolves die, deer don't explode)
### Test 2: EVE Online Economy (500K Players, Market Balance)
**Setup**: 10 resource types, 100+ production/consumption chains, dynamic pricing.
**Difficulty**: Traders respond to price signals, creating complex feedback.
**Stability checks**:
```python
# Simplified EVE-like economy
class EVEEconomy:
def __init__(self, n_resources=10):
self.n = n_resources
self.prices = 100 * np.ones(n_resources) # Initial prices
self.supply = 1000 * np.ones(n_resources)
self.demand = 1000 * np.ones(n_resources)
def update_production(self, trader_count):
"""
Miners/traders respond to price signals.
High price → more production.
"""
for i in range(self.n):
production = trader_count * 50 * (self.prices[i] / 100)
self.supply[i] = 0.9 * self.supply[i] + 0.1 * production
def update_demand(self, player_count):
"""Factories/consumers constant demand based on player count"""
for i in range(self.n):
self.demand[i] = player_count * 10 # Per-player demand
def update_prices(self):
"""
Price adjustment based on supply/demand imbalance.
Market clearing mechanism.
"""
for i in range(self.n):
imbalance = (self.demand[i] - self.supply[i]) / self.demand[i]
self.prices[i] *= 1.0 + 0.1 * imbalance
self.prices[i] = max(1, self.prices[i]) # Prevent negative prices
def simulate(self, trader_count, player_count, duration=1000):
"""Run simulation for duration time steps"""
price_history = []
supply_history = []
for t in range(duration):
self.update_production(trader_count)
self.update_demand(player_count)
self.update_prices()
price_history.append(self.prices.copy())
supply_history.append(self.supply.copy())
return np.array(price_history), np.array(supply_history)
# Test 1: Stable with 100K players
economy = EVEEconomy(n_resources=10)
prices_100k, supply_100k = economy.simulate(trader_count=500, player_count=100000, duration=1000)
# Check if prices stabilize
price_change = np.std(prices_100k[-200:]) / np.mean(prices_100k)
print(f"With 100K players: Price volatility = {price_change:.4f}")
if price_change < 0.05:
print("✓ Prices stable")
else:
print("✗ Prices oscillating/unstable")
# Test 2: Stability with 500K players (10x more)
economy = EVEEconomy(n_resources=10)
prices_500k, supply_500k = economy.simulate(trader_count=2500, player_count=500000, duration=1000)
price_change = np.std(prices_500k[-200:]) / np.mean(prices_500k)
print(f"With 500K players: Price volatility = {price_change:.4f}")
if price_change < 0.05:
print("✓ Prices stable at 5x scale")
else:
print("✗ Economy unstable at 5x scale!")
# Plot comparison
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
ax = axes[0]
ax.plot(prices_100k[:, 0], label='100K players', linewidth=1)
ax.set_ylabel('Price (first resource)')
ax.set_title('Price Stability: 100K Players')
ax.grid(True)
ax.legend()
ax = axes[1]
ax.plot(prices_500k[:, 0], label='500K players', linewidth=1, color='orange')
ax.set_xlabel('Time steps')
ax.set_ylabel('Price (first resource)')
ax.set_title('Price Stability: 500K Players')
ax.grid(True)
ax.legend()
plt.tight_layout()
plt.show()
```
**Stability requirements:**
- ✓ Prices within 5% of equilibrium after 200 steps
- ✓ No hyperinflation (prices not growing exponentially)
- ✓ Scales to 5x player count without instability
- ✓ Supply/demand close to balanced
### Test 3: Flocking AI Formation (500-Unit Squad)
**Setup**: RTS unit formation with cohesion, separation, alignment forces.
**Difficulty**: At scale, forces interact unpredictably. Need eigenvalue analysis.
```python
class FlockingFormation:
def __init__(self, n_units=100, dt=0.016):
self.n = n_units
self.dt = dt
# Initialize units in formation
self.pos = np.random.randn(n_units, 2) * 0.1 # Clustered near origin
self.vel = np.zeros((n_units, 2))
def get_nearby_units(self, unit_idx, radius=5.0):
"""Find units within radius"""
distances = np.linalg.norm(self.pos - self.pos[unit_idx], axis=1)
nearby = np.where((distances < radius) & (distances > 0))[0]
return nearby
def cohesion_force(self, unit_idx, cohesion_strength=0.1):
"""Pull toward average position of nearby units"""
nearby = self.get_nearby_units(unit_idx)
if len(nearby) == 0:
return np.array([0, 0])
center = np.mean(self.pos[nearby], axis=0)
direction = center - self.pos[unit_idx]
# Soft stiffness to avoid oscillation
return cohesion_strength * direction / (np.linalg.norm(direction) + 1e-6)
def separation_force(self, unit_idx, separation_strength=0.5):
"""Push away from nearby units"""
nearby = self.get_nearby_units(unit_idx, radius=2.0)
if len(nearby) == 0:
return np.array([0, 0])
forces = np.zeros(2)
for other_idx in nearby:
direction = self.pos[unit_idx] - self.pos[other_idx]
dist = np.linalg.norm(direction) + 1e-6
forces += separation_strength * direction / (dist + 0.1)
return forces / (len(nearby) + 1)
def alignment_force(self, unit_idx, alignment_strength=0.05):
"""Align velocity with nearby units"""
nearby = self.get_nearby_units(unit_idx)
if len(nearby) == 0:
return np.array([0, 0])
avg_vel = np.mean(self.vel[nearby], axis=0)
return alignment_strength * avg_vel
def step(self):
"""Update all units"""
forces = np.zeros((self.n, 2))
for i in range(self.n):
forces[i] = (self.cohesion_force(i) +
self.separation_force(i) +
self.alignment_force(i))
# Update velocities and positions
self.vel += forces * self.dt
self.pos += self.vel * self.dt
# Damping to prevent unstable oscillations
self.vel *= 0.95
def formation_stability(self):
"""Measure how tight the formation is"""
# Standard deviation of positions
std_x = np.std(self.pos[:, 0])
std_y = np.std(self.pos[:, 1])
return std_x + std_y
def simulate(self, duration=1000):
"""Run simulation, measure stability"""
stability_history = []
for t in range(duration):
self.step()
stability = self.formation_stability()
stability_history.append(stability)
return stability_history
# Test at different scales
for n_units in [10, 100, 500]:
formation = FlockingFormation(n_units=n_units)
stability = formation.simulate(duration=1000)
final_stability = np.mean(stability[-100:]) # Average last 100 steps
print(f"{n_units} units: Formation radius = {final_stability:.2f}")
if final_stability > 10.0:
print(f" ✗ UNSTABLE - formation exploded")
elif final_stability < 0.5:
print(f" ✓ STABLE - tight formation")
else:
print(f" ⚠️ MARGINAL - formation loose but stable")
# Plot formation evolution
formation = FlockingFormation(n_units=100)
stability = formation.simulate(duration=1000)
plt.figure(figsize=(10, 6))
plt.plot(stability, linewidth=1)
plt.xlabel('Time steps (60 FPS)')
plt.ylabel('Formation radius (meters)')
plt.title('100-Unit Formation Stability')
plt.grid(True)
plt.show()
```
**Stability requirements:**
- ✓ Formation radius stabilizes (not growing exponentially)
- ✓ Scales to 500 units without explosion
- ✓ No units pass through each other
- ✓ Formation remains compact (radius < 20 meters)
### Test 4: Ragdoll Physics Stability
**Setup**: Ragdoll corpse with joint constraints. Test at different framerates.
```python
class RagdollSegment:
def __init__(self, pos, mass=1.0):
self.pos = np.array(pos, dtype=float)
self.old_pos = self.pos.copy()
self.mass = mass
self.acceleration = np.zeros(2)
def apply_force(self, force):
self.acceleration += force / self.mass
def verlet_step(self, dt, gravity=[0, -9.8]):
"""Verlet integration"""
# Apply gravity
self.apply_force(np.array(gravity) * self.mass)
# Verlet integration
vel = self.pos - self.old_pos
self.old_pos = self.pos.copy()
self.pos += vel + self.acceleration * dt * dt
self.acceleration = np.zeros(2)
class RagdollConstraint:
def __init__(self, seg_a, seg_b, rest_length):
self.seg_a = seg_a
self.seg_b = seg_b
self.rest_length = rest_length
def solve(self, stiffness=0.95, iterations=5):
"""Solve constraint: keep segments at rest_length"""
for _ in range(iterations):
delta = self.seg_b.pos - self.seg_a.pos
dist = np.linalg.norm(delta)
if dist < 1e-6:
return
diff = (dist - self.rest_length) / dist
offset = delta * diff * (1 - stiffness)
# Move both segments
self.seg_a.pos += offset * 0.5
self.seg_b.pos -= offset * 0.5
class Ragdoll:
def __init__(self, dt=1/60):
self.dt = dt
# 5-segment ragdoll
self.segments = [
RagdollSegment([0, 5], mass=1.0), # Head
RagdollSegment([0, 3], mass=2.0), # Torso
RagdollSegment([-1, 1], mass=0.5), # Left arm
RagdollSegment([1, 1], mass=0.5), # Right arm
RagdollSegment([0, -2], mass=1.0), # Legs
]
self.constraints = [
RagdollConstraint(self.segments[0], self.segments[1], 2.0),
RagdollConstraint(self.segments[1], self.segments[2], 1.5),
RagdollConstraint(self.segments[1], self.segments[3], 1.5),
RagdollConstraint(self.segments[1], self.segments[4], 2.5),
]
def step(self):
"""Simulate one physics step"""
# Integrate
for seg in self.segments:
seg.verlet_step(self.dt)
# Satisfy constraints
for constraint in self.constraints:
constraint.solve(stiffness=0.95, iterations=5)
def energy(self):
"""Total kinetic energy (stability measure)"""
energy = 0
for seg in self.segments:
vel = seg.pos - seg.old_pos
energy += seg.mass * np.linalg.norm(vel)**2
return energy
def simulate(self, duration_steps=1000):
"""Run simulation, measure stability"""
energy_history = []
for t in range(duration_steps):
self.step()
energy_history.append(self.energy())
return energy_history
# Test at different framerates
framerates = [60, 120, 144, 240]
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
for idx, fps in enumerate(framerates):
dt = 1.0 / fps
ragdoll = Ragdoll(dt=dt)
energy = ragdoll.simulate(duration_steps=1000)
ax = axes[idx // 2, idx % 2]
ax.plot(energy, linewidth=1)
ax.set_title(f'Ragdoll Energy at {fps} FPS (dt={dt:.4f})')
ax.set_xlabel('Time steps')
ax.set_ylabel('Kinetic energy')
ax.set_yscale('log')
ax.grid(True)
final_energy = np.mean(energy[-100:])
initial_energy = np.mean(energy[:100])
if final_energy < initial_energy:
ax.text(0.5, 0.95, '✓ STABLE', transform=ax.transAxes,
ha='center', va='top', fontsize=14, color='green',
bbox=dict(boxstyle='round', facecolor='white', alpha=0.7))
else:
ax.text(0.5, 0.95, '✗ EXPLODING', transform=ax.transAxes,
ha='center', va='top', fontsize=14, color='red',
bbox=dict(boxstyle='round', facecolor='white', alpha=0.7))
plt.tight_layout()
plt.show()
# Critical timestep analysis
print("\nCritical timestep analysis:")
print("For stable Verlet integration of spring-like systems:")
print("dt_critical ≈ 2/ω₀ where ω₀ = sqrt(k/m)")
print("\nFor ragdoll: spring stiffness k ≈ 0.95, mass m ≈ 1.0")
print("ω₀ ≈ 0.974 rad/s")
print("dt_critical ≈ 2.05 seconds (!)")
print("\nAt 60 FPS: dt = 0.0167 << 0.0001 (safe)")
print("At 240 FPS: dt = 0.0042 still << 0.0001 (safe)")
print("System should be stable at all tested framerates.")
```
**Stability requirements:**
- ✓ Energy decays exponentially (damping dominates)
- ✓ No energy growth at 60, 120, 144, 240 FPS
- ✓ No oscillations in kinetic energy
- ✓ System settles within 500 steps
### Test 5: Fighting Game Character Balance
**Setup**: 8 characters with matchup matrix (damage, startup, recovery, etc.).
**Difficulty**: Small parameter changes can shift metagame dramatically.
```python
import numpy as np
class FighterCharacter:
def __init__(self, name, damage=10, startup=5, recovery=8):
self.name = name
self.damage = damage # Damage per hit
self.startup = startup # Frames before attack lands
self.recovery = recovery # Frames before next attack
self.health = 100
def dps_vs(self, other):
"""Damage per second vs other character"""
# Assume each hit lands with probability proportional to (1 - recovery/startup)
hits_per_second = 60 / (self.startup + self.recovery)
dps = hits_per_second * self.damage
return dps
class FightingGameBalance:
def __init__(self):
self.characters = {
'Ryu': FighterCharacter('Ryu', damage=8, startup=4, recovery=6),
'Ken': FighterCharacter('Ken', damage=9, startup=5, recovery=5),
'Chun': FighterCharacter('Chun', damage=6, startup=3, recovery=8),
'Guile': FighterCharacter('Guile', damage=10, startup=6, recovery=5),
'Zangief': FighterCharacter('Zangief', damage=14, startup=7, recovery=8),
'Blanka': FighterCharacter('Blanka', damage=7, startup=3, recovery=7),
'E.Honda': FighterCharacter('E.Honda', damage=12, startup=8, recovery=4),
'Dhalsim': FighterCharacter('Dhalsim', damage=5, startup=2, recovery=10),
}
def compute_matchup_matrix(self):
"""
Matchup winrate matrix.
M[i][j] = probability that character i beats character j
Based on DPS ratio.
"""
chars = list(self.characters.values())
n = len(chars)
M = np.zeros((n, n))
for i in range(n):
for j in range(n):
if i == j:
M[i][j] = 0.5 # Even matchup
else:
dps_i = chars[i].dps_vs(chars[j])
dps_j = chars[j].dps_vs(chars[i])
# Logistic: winrate = 1 / (1 + exp(-(dps_i - dps_j)))
winrate = 1 / (1 + np.exp(-(dps_i - dps_j)))
M[i][j] = winrate
return M
def replicator_dynamics(self, pick_probs, matchup_matrix):
"""
How player pick distribution evolves based on winrates.
dP_i/dt = P_i * (winrate_i - average_winrate)
"""
winrates = matchup_matrix @ pick_probs
avg_winrate = np.mean(winrates)
dp = pick_probs * (winrates - avg_winrate)
return dp
def simulate_meta_evolution(self, duration=1000):
"""Simulate how metagame evolves over time"""
n_chars = len(self.characters)
pick_probs = np.ones(n_chars) / n_chars # Equal picks initially
matchup_matrix = self.compute_matchup_matrix()
evolution = [pick_probs.copy()]
for t in range(duration):
dp = self.replicator_dynamics(pick_probs, matchup_matrix)
pick_probs = pick_probs + 0.01 * dp # Small step
pick_probs = np.clip(pick_probs, 1e-3, 1.0) # Prevent extinction
pick_probs = pick_probs / np.sum(pick_probs) # Renormalize
evolution.append(pick_probs.copy())
return np.array(evolution)
def test_balance(self):
"""Test if metagame is balanced"""
evolution = self.simulate_meta_evolution(duration=1000)
char_names = list(self.characters.keys())
final_picks = evolution[-1]
# Check if any character dominates
max_pick_rate = np.max(final_picks)
min_pick_rate = np.min(final_picks)
print("Final metagame pick rates:")
for i, name in enumerate(char_names):
print(f" {name}: {final_picks[i]:.1%}")
# Balanced if all characters have similar pick rates
std_dev = np.std(final_picks)
print(f"\nBalance metric (standard deviation of pick rates): {std_dev:.4f}")
if std_dev < 0.05:
print("✓ BALANCED - All characters equally viable")
elif std_dev < 0.10:
print("⚠️ SLIGHTLY IMBALANCED - Some characters stronger")
else:
print("✗ SEVERELY IMBALANCED - Metagame dominated by few characters")
# Plot evolution
plt.figure(figsize=(12, 6))
for i, name in enumerate(char_names):
plt.plot(evolution[:, i], label=name, linewidth=2)
plt.xlabel('Patch iterations')
plt.ylabel('Pick rate')
plt.title('Fighting Game Metagame Evolution')
plt.legend()
plt.grid(True)
plt.show()
return std_dev
balance = FightingGameBalance()
balance.test_balance()
```
**Stability requirements:**
- ✓ Metagame converges (pick rates stabilize)
- ✓ No character above 30% pick rate
- ✓ No character below 5% pick rate
- ✓ Multiple viable playstyles (pick rate std dev < 0.10)
### Test 6: Game Balance Patch Stability
**Setup**: Balance patch changes 10 character parameters. Check if system becomes more balanced or less.
```python
def compare_balance_before_after_patch():
"""
Simulate two versions: original and patched.
Check if patch improves or degrades balance.
"""
# Original balance
balance_old = FightingGameBalance()
std_old = balance_old.test_balance()
# Patch: Try to buff weak characters, nerf strong characters
print("\n" + "="*50)
print("Applying balance patch...")
print("="*50)
# Identify weak and strong
evolution = balance_old.simulate_meta_evolution()
final_picks = evolution[-1]
char_names = list(balance_old.characters.keys())
# Patch: damage adjustment based on pick rate
for i, name in enumerate(char_names):
char = balance_old.characters[name]
if final_picks[i] < 0.1: # Underpicked, buff
char.damage *= 1.1
print(f"Buffed {name}: damage {char.damage/1.1:.1f}{char.damage:.1f}")
elif final_picks[i] > 0.15: # Overpicked, nerf
char.damage *= 0.9
print(f"Nerfed {name}: damage {char.damage/0.9:.1f}{char.damage:.1f}")
# Check balance after patch
print("\nBalance after patch:")
std_new = balance_old.test_balance()
# Compare
print(f"\n" + "="*50)
print(f"Balance improvement: {(std_old - std_new)/std_old:.1%}")
if std_new < std_old:
print("✓ Patch improved balance")
else:
print("✗ Patch worsened balance")
print("="*50)
compare_balance_before_after_patch()
```
## Conclusion
**Key takeaways for game systems stability:**
1. **Always find equilibria first** - Know where your system "wants" to be
2. **Check eigenvalues** - Stability is determined by numbers, not intuition
3. **Test at scale** - Parameter that works at 100 units may fail at 500
4. **Watch for bifurcations** - Small parameter changes can cause sudden instability
5. **Use Lyapunov for nonlinear** - When Jacobians are inconclusive
6. **Numerical stability matters** - Framerate and integration method affect stability
7. **Model player behavior** - Systems with feedback loops are unstable if players respond
8. **Verify with long simulations** - 10x longer than gameplay to catch divergence
9. **Create testing framework** - Automate stability checks into build pipeline
**When your next game system crashes:**
Before tweaking parameters randomly, ask:
- What's the equilibrium point?
- Is it stable (negative eigenvalues)?
- What happens if I perturb it slightly?
- Are there multiple equilibria or bifurcations?
- How does it scale as player count increases?
This skill teaches you to answer these questions rigorously.
## Further Reading
**Academic References:**
- Strogatz, S. H. "Nonlinear Dynamics and Chaos: With Applications to Physics, Biology, Chemistry, and Engineering"
- Guckenheimer, J. & Holmes, P. "Nonlinear Oscillations, Dynamical Systems, and Bifurcations of Vector Fields"
**Game Development:**
- Swink, S. "Game Feel: A Game Programmer's Guide to Virtual Sensation"
- Salen, K. & Zimmerman, E. "Rules of Play: Game Design Fundamentals"
**Tools:**
- PyDSTool: Dynamical systems toolkit for Python
- Matcont: Continuation and bifurcation software
- Mathematica/WolframLanguage: Symbolic stability analysis