Sign in

    differentiable_ecs

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

    optimization
    cma-es
    balance-tuning
    game-design
    moonbit
    Download zip
    Author
    Version
    0.2.1
    License
    MIT
    Last updated
    4 months ago
    Downloads
    635

    #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