differentiable_ecs

Game-agnostic parameter optimizer using CMA-ES for balance tuning (experimental)

optimization
cma-es
balance-tuning
game-design
moonbit
moon add mizchi/differentiable_ecs@0.2.1
Download zip
Author
Version
0.2.1
License
MIT
Last updated
2 months ago
Downloads
289
README

#differentiable_ecs

Game-agnostic parameter optimizer using CMA-ES for balance tuning. (experimental)

A pure MoonBit implementation of CMA-ES (Covariance Matrix Adaptation Evolution Strategy) designed for game balance optimization, but applicable to any black-box parameter tuning problem.

#Install

moon add mizchi/differentiable_ecs

#Quick Start

fn simulate(params : Array[Double], seed : Int) -> @dec.Metrics {
let speed = params[0]
let hp = params[1]
// ... run your simulation ...
let m = @dec.Metrics::new()
m.set("survival_time", 120.0)
m.set("kill_rate", 3.5)
m
}

fn main {
let specs : Array[@dec.ParamSpec] = [
{ name: "speed", initial: 2.0, min: 0.5, max: 5.0 },
{ name: "hp", initial: 100.0, min: 10.0, max: 500.0 },
]
let targets : Array[@dec.BalanceTarget] = [
{ metric: "survival_time", target: 180.0, weight: 1.0 },
{ metric: "kill_rate", target: 5.0, weight: 1.5 },
]
let config = { ..@dec.TuneConfig::default(specs, targets), max_generations: 50 }
let result = @dec.tune(config, simulate)
println("Loss: " + result.loss.to_string())
}

#How It Works

  1. Define parameters (ParamSpec): name, initial value, min/max bounds
  2. Write a simulation function: (Array[Double], Int) -> Metrics — takes parameter values and a seed, returns named metrics
  3. Set targets (BalanceTarget): which metrics to optimize, target values, and weights
  4. Call tune(): CMA-ES finds parameters that minimize weighted squared error between actual and target metrics

The optimizer runs your simulation multiple times per generation (across multiple seeds) to find robust parameter settings.

#API

#Types

TypeDescription
MetricsMap[String, Double] wrapper for simulation results
ParamSpec{ name, initial, min, max } — parameter definition
BalanceTarget{ metric, target, weight } — optimization target
TuneConfigFull configuration (specs, targets, seeds, generations, sigma)
TuneResult{ params, loss, generation, metrics } — optimization result

#Functions

FunctionSignatureDescription
tune(TuneConfig, sim_fn) -> TuneResultRun CMA-ES optimization
TuneConfig::default(specs, targets) -> TuneConfigCreate config with sensible defaults
sq_err(actual, target) -> DoubleSquared relative error
compute_loss(Metrics, targets) -> DoubleCompute loss from metrics
compute_loss_averaged(sim, params, targets, seeds) -> (Double, Metrics)Multi-seed averaged loss

#TuneConfig Defaults

seeds: [42, 137, 256, 512] (4 seeds for robustness) max_generations: 40 sigma: 0.3 (initial step size) rng_seed: 42 log_interval: 10 (0 to disable)

#When to Use CMA-ES

CMA-ES adapts a full covariance matrix, learning variable dependencies and landscape curvature — equivalent to approximating the inverse Hessian without gradients.

#Effective (use this library)

  • Non-separable problems: parameters interact with each other (game balance, physics tuning)
  • Ill-conditioned landscapes: different parameters have vastly different sensitivities
  • Moderate dimensions: 5–100 parameters
  • Black-box functions: no gradient available (simulations, game loops)
  • Noisy evaluations: multi-seed averaging handles stochastic simulations

#Less effective (consider alternatives)

  • Highly multimodal: many local minima (Rastrigin-like) — needs restart strategies (IPOP-CMA-ES)
  • Deceptive landscapes: global optimum far from initial guess (Schwefel-like)
  • Very high dimensions: >200 parameters — use diagonal CMA-ES or other methods
  • Budget < 10n evaluations: too few to learn the covariance structure

#Benchmark Results

Validated against standard test functions from Hansen (2016) "The CMA Evolution Strategy: A Tutorial":

FunctionnResultLossExpected
Sphere10PASS<1e-10Trivial for CMA-ES
Rosenbrock5PASS<1e-10CMA-ES learns valley curvature
Rosenbrock10PASS<1e-10Needs ~1000 gens to learn 10D valley
Rastrigin5FAIL99.0Multimodal — needs restarts
Ackley10PASS4.4e-8Narrow basin detection (500 gens)
Schwefel5FAIL1.2MDeceptive landscape
Griewank10GOOD0.0004Non-separable interactions

All results match expected CMA-ES behavior from the literature.

#Examples

  • examples/benchmark/ — Standard optimization benchmarks (Sphere, Rosenbrock, Rastrigin, Ackley, Schwefel, Griewank)
  • examples/hacknslash/ — Hack-and-slash game balance (player speed, enemy HP, spawn rate)
  • examples/tower_defense/ — Tower defense wave balancing (enemy scaling, tower stats)
  • examples/deckbuilder/ — Card game archetype balance (3 archetypes, ~50% win rates)
  • examples/sim_game/ — Economy simulation (tax, production, growth)

#Performance

Per-generation wall-clock time (Sphere objective, 1 seed, Apple Silicon):

nJS/V8 (ms/gen)Native release (ms/gen)
50.120.03
100.190.15
200.601.35
5013.9032.85
100193402

The bottleneck is Jacobi eigendecomposition (O(n^4) worst case per generation). For n > 30, V8's JIT outperforms native due to hot-loop optimization.

For game balance tuning (n=5..30), performance is not a concern — runs complete in under a second. For n > 50, consider Cholesky-based CMA-ES or GPU acceleration (Metal/WGSL compute shaders).

#References

  • Hansen, N. (2016). The CMA Evolution Strategy: A Tutorial. arXiv:1604.00772.
  • Hansen, N. & Ostermeier, A. (2001). Completely Derandomized Self-Adaptation in Evolution Strategies. Evolutionary Computation, 9(2), 159-195.

#License

MIT

#
BalanceTarget

pub(all) struct BalanceTarget {
metric : String
target : Double
weight : Double
}
A single optimization target: metric name, target value, weight

#
Metrics

pub struct Metrics(Map[String, Double])
Simulation result: flexible key-value metrics

#
Metrics::each

fn Metrics::each(self : Metrics, f : (String, Double) -> Unit) -> Unit
Iterate over all metric entries

#
Metrics::get

fn Metrics::get(self : Metrics, key : String) -> Double
Get a metric value, defaulting to 0.0 if not found

#
Metrics::new

fn Metrics::new() -> Metrics
Create empty metrics

#
Metrics::set

fn Metrics::set(self : Metrics, key : String, value : Double) -> Unit
Set a metric value

#
ParamSpec

pub(all) struct ParamSpec {
name : String
initial : Double
min : Double
max : Double
}
Parameter specification: name, initial value, min, max

#
TuneConfig

pub(all) struct TuneConfig {
param_specs : Array[ParamSpec]
targets : Array[BalanceTarget]
seeds : Array[Int]
max_generations : Int
sigma : Double
rng_seed : Int
log_interval : Int
}
Configuration for a tuning run

#
TuneConfig::default

fn TuneConfig::default(param_specs : Array[ParamSpec], targets : Array[BalanceTarget]) -> TuneConfig

#
TuneResult

pub(all) struct TuneResult {
params : Array[Double]
loss : Double
generation : Int
metrics : Metrics
}
Result of a tuning run

#
average_metrics

fn average_metrics(all : Array[Metrics]) -> Metrics
Average multiple metrics maps (per-key averaging)

#
clamp_params

fn clamp_params(params : Array[Double], specs : Array[ParamSpec]) -> Array[Double]
Clamp params array according to specs

#
compute_loss

fn compute_loss(metrics : Metrics, targets : Array[BalanceTarget]) -> Double
Compute loss from metrics against targets

#
compute_loss_averaged

fn compute_loss_averaged(sim : (Array[Double], Int) -> Metrics, params : Array[Double], targets : Array[BalanceTarget], seeds : Array[Int]) -> (Double, Metrics)
Run multiple seeds, compute averaged loss and metrics

#
initial_params

fn initial_params(specs : Array[ParamSpec]) -> Array[Double]
Extract initial values from param specs

#
param_names

fn param_names(specs : Array[ParamSpec]) -> Array[String]
Extract param names from specs

#
sq_err

fn sq_err(actual : Double, target : Double) -> Double
Squared relative error

#
tune

fn tune(config : TuneConfig, sim : (Array[Double], Int) -> Metrics) -> TuneResult
Run CMA-ES optimization