73 KiB
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:
# 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:
# 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:
// 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:
# 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:
// 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:
# 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:
# 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:
# 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:
# 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:
# 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:
// 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:
- Health regeneration equilibrium:
# 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
- Economy price equilibrium:
# 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
- Population equilibrium (Lotka-Volterra):
# 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:
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):
- Find equilibrium: f(x*) = 0
- Compute Jacobian matrix: J[i,j] = ∂f_i/∂x_j
- Evaluate at equilibrium: J(x*)
- Compute eigenvalues of J(x*)
- Interpret stability
Example: Predator-prey (Lotka-Volterra)
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
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
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:
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:
- V(x*) = 0 (minimum at equilibrium)
- V(x) > 0 for all x ≠ x* (positive everywhere else)
- dV/dt < 0 along trajectories (energy decreases over time)
If all three conditions hold, equilibrium is globally stable.
Example: Damped pendulum
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
# 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:
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)
# 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
# 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
# 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):
# 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:
# 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:
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:
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:
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:
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
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
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:
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:
# 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.
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.
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.
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.
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:
- Always find equilibria first - Know where your system "wants" to be
- Check eigenvalues - Stability is determined by numbers, not intuition
- Test at scale - Parameter that works at 100 units may fail at 500
- Watch for bifurcations - Small parameter changes can cause sudden instability
- Use Lyapunov for nonlinear - When Jacobians are inconclusive
- Numerical stability matters - Framerate and integration method affect stability
- Model player behavior - Systems with feedback loops are unstable if players respond
- Verify with long simulations - 10x longer than gameplay to catch divergence
- 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