Dynamo.js
Example · Divide the Dollar

Divide the Dollar

Does fair division emerge from blind evolution? Brian Skyrms's classic demonstration that it (mostly) does — his original C program, and how the same model is a handful of lines with the Dynamo toolkit, built up in stages to the full study.

Introduction

In Evolution of the Social Contract (1996) Brian Skyrms asks whether the norm of fair division can arise with no designer, purely through the differential reproduction of strategies. His test case is the Nash demand game, "divide the dollar", run under the replicator dynamic; the finding is that from the great majority of starting populations the system converges on the equal split.

Skyrms wrote a short, fast C program to show it. This page reproduces that program with the Dynamo toolkit — and the point of interest is that, because the toolkit supplies the game, the dynamics and the integrator, the reproduction is shorter than the original, not longer. We start with that minimal version, then build up to the full study.

The game

Two players each demand a whole number of dimes, 0 through 10. If the demands are compatible — they sum to no more than the ten-dime dollar — each gets what they asked for; otherwise both get nothing:

payoff(demand i against demand j) = i   if i + j ≤ 10
                                  = 0   otherwise

Every split (i, 10−i) is a Nash equilibrium, as is the equal split (5, 5). Under the replicator these are the rest points: the equal split, and the polymorphisms in which a fraction demands i and the rest demand 10−i. Which does evolution pick?

Skyrms’s program in C

Here is Skyrms's source in full. It hand-codes the payoff matrix FIT, then iterates the discrete replicator map with a background fitness c = 10P[i] ← P[i]·(U[i]+c)/(Ū+c) — from a fixed mix of demands 1, 3, 7 and 9, checking for convergence.



#include <stdio.h>
#include <math.h>
#include <stdlib.h>
#include <float.h>
#include <time.h>

#define G 11
#define ranmax 32769.00000
#define ranmax RAND_MAX

#define NOT_CONVERGED 0
#define CONVERGED     1

main()
{
  
  double FIT[G][G], P[G], U[G], meant, USQ, c, norm;
  unsigned long int i, j, trials, tr, tgen, maxt, gen, conv, COUNT[G];
  
  
  tgen = 0;
  meant = 0.0000000;
  maxt = 0;
  
  for (i=0; i<G; i++)
    COUNT[i]=0;
  
  for (i=0; i<G; i++)
      for(j=0; j<G; j++)
          if (i+j < G)
              FIT[i][j]=i;
          else
              FIT[i][j]=0.0;
  
  c=10.00;
  srand( (unsigned)time( NULL ) );
  trials = 0;
  
  for (tr=0; tr<1; tr++)
  {
     
     norm = 0.000000000;
     
     for (i=0; i<G; i++)
         P[i] = -log((rand()+1.000)/ranmax);
     
     for (i=0; i<G; i++)
         norm = norm + P[i];
     
     for (i=0; i<G; i++)
         P[i] = P[i]/norm;
     
     for (i=0; i<G; i++)
       P[i] = 0.0;

     P[3] = 0.25; P[7] = 0.25; P[1] = .25; P[9]=.25;

     gen = 0;
     conv = NOT_CONVERGED;
     
     
     do{

       printf("\n");
       
       for (i=0; i<G; i++) 
	 printf("%f ", P[i]);

       gen = gen + 1;
         
       for (i=0; i<G; i++)
             U[i]=0.00;
         
         for (i=0; i<G; i++)
             for (j=0; j<G; j++)
                 U[i] = U[i] + FIT[i][j]*P[j];
         
         
         USQ=0.00000;
         
         for (i=0; i<G; i++)
             USQ = USQ + P[i] * U[i];
         
         
         for (i=0; i<G; i++)
             P[i] = P[i] * ((U[i] + c)/(USQ + c));
         
         
         if (P[5]>.9999)           conv = CONVERGED;
         if (P[4] + P[6] > .9999)  conv = CONVERGED;
         if (P[3] + P[7] > .9999)  conv = CONVERGED;
         if (P[2] + P[8] > .9999)  conv = CONVERGED;
         if (P[1] + P[9] > .9999)  conv = CONVERGED;
         if (P[0] + P[10] > .9999) conv = CONVERGED;
         
     } while (gen < 10000);

       /* (conv == NOT_CONVERGED && gen < 1000); */
     
     
     if (gen > maxt)
         maxt = gen;
     trials = trials + 1;
     tgen = tgen + gen;
     meant = ((double)tgen)/((double)trials);
     
     for (i=0; i<G; i++)
         if (P[i] > .1)
             COUNT[i] = (COUNT[i]+1);
     
  }
  
  printf("Divide a stack of ten dimes - 100,000 trials\n");
  printf("mean number of generations to convergence at .9999  level  %f, \n", meant);
  printf("max number of generations to convergence at .9999 level %d,\n\n", maxt);
  
  for (i=0; i<G; i++)
    if (COUNT[i]>=0)
      printf("%d,   %d\n", i, COUNT[i]);
  
  return 0;
}

The same model, in a few lines

The toolkit removes the scaffolding. The payoff matrix is one expression handed to Game.symmetric, which carries the expected-payoff machinery; the dynamics are a named field handed to an integrator, which does the iterating. Two correspondences make the saving concrete:

Building the game

Skyrms (C)

double FIT[G][G];
for (i = 0; i < G; i++)
  for (j = 0; j < G; j++)
    FIT[i][j] = (i + j < G) ? i : 0.0;   /* compatible? get your demand : nothing */

Dynamo (JS)

const A = Array.from({ length: N }, (_, i) =>
  Array.from({ length: N }, (_, j) => (i + j <= N - 1 ? i : 0)));
const game = Game.symmetric(A);          // …and the payoff machinery comes with it

Evolving the population

Skyrms (C) — one generation, by hand

for (i = 0; i < G; i++) U[i] = 0;            /* expected payoffs  */
for (i = 0; i < G; i++)
  for (j = 0; j < G; j++) U[i] += FIT[i][j] * P[j];
for (i = 0; i < G; i++) USQ += P[i] * U[i];  /* mean fitness      */
for (i = 0; i < G; i++)
  P[i] = P[i] * ((U[i] + c) / (USQ + c));     /* and step forward */

Dynamo (JS) — the whole run

// Hand the field to the integrator — it does the iterating for you:
const end = trajectory(game, replicator, start, { tMax: 60 }).states.at(-1)[0];

Put together — game, a faithful copy of Skyrms's exact discrete map, and a small experiment — the complete short program is this:

#!/usr/bin/env node
/**
 * core.mjs — Skyrms's "divide the dollar", the short way.
 *
 * The whole model in a handful of lines: the toolkit supplies the game, the
 * replicator field and the integrator, so there is no payoff loop and no update
 * loop to hand-code. Compare with Skyrms's C in ../converge.c.
 */
import { Game, replicator, trajectory, flow, Random } from '../../src/index.js';

// The demand game: demand i of 10 dimes; get it if i + j ≤ 10, else nothing.
const N = 11;
const A = Array.from({ length: N }, (_, i) => Array.from({ length: N }, (_, j) => (i + j <= N - 1 ? i : 0)));
const game = Game.symmetric(A);

const fmt = (p) => p.map((x) => (x < 5e-3 ? '  ·  ' : x.toFixed(3))).join(' ');
const skyrms = [[0, 0.25, 0, 0.25, 0, 0, 0, 0.25, 0, 0.25, 0]]; // demands 1,3,7,9

// (1) Reproduce Skyrms's run — one call to the integrator does the iterating.
const end = trajectory(game, replicator, skyrms, { tMax: 60 }).states.at(-1)[0];
console.log('replicator, Skyrms start :', fmt(end));   // → the 3 | 7 polymorphism

// (2) Skyrms's *exact* discrete map, if you want it — the toolkit still gives
//     you the fitnesses, so it is four lines, not forty.
let p = skyrms[0], c = 10;
for (let gen = 0; gen < 300; gen++) {
  const U = game.payoff([p])[0];                         // expected payoff of each demand
  const Ubar = p.reduce((s, pi, i) => s + pi * U[i], 0); // mean fitness
  p = p.map((pi, i) => (pi * (U[i] + c)) / (Ubar + c));  // discrete replicator
}
console.log('discrete map, Skyrms start:', fmt(p));      // → the same 3 | 7

// (3) The experiment: where do random starts go? `Random.simplex` draws a
//     uniform random population, so a trial is a single line.
const rng = new Random(1);
let fair = 0, trials = 5000;
for (let k = 0; k < trials; k++) {
  const f = flow(game, replicator, [rng.simplex(N)], 80)[0];
  if (f[5] > 0.99) fair++;
}
console.log(`\nfair division from ${trials} uniform random starts: ${(100 * fair / trials).toFixed(0)}%`);
replicator, Skyrms start :   ·     ·     ·   0.429   ·     ·     ·   0.571   ·     ·     ·  
discrete map, Skyrms start:   ·     ·     ·   0.429   ·     ·     ·   0.571   ·     ·     ·  

fair division from 5000 uniform random starts: 60%

So is the library a win? Skyrms's C is 82 non-blank lines; the program above is 22 lines of code and does more (the continuous run, his exact discrete map, and an experiment). The library is shorter precisely where it should be — there is no payoff loop and no update loop to write. The longer file later in this page is long only because of the hand-drawn SVG figure, not the simulation.

Discrete vs. continuous. Dynamo's replicator is the continuous-time field ẋ_i = x_i·(U_i − Ū); Skyrms iterates a discrete map. They share rest points and basins (adding a constant to every payoff leaves the field unchanged), so the result is the same.

Scaling up: the experiment

Skyrms's banner — "divide a stack of ten dimes, 100,000 trials" — points at the real study: not one run, but many random starts, asking how often each equilibrium is reached. With the toolkit a trial is one line, because Random.simplex draws a random population for you:

const rng = new Random(1);
const tally = {};
for (let k = 0; k < 100000; k++) {
  const end = flow(game, replicator, [rng.simplex(N)], 80)[0];  // random start → rest
  tally[bucket(end)] = (tally[bucket(end)] || 0) + 1;           // which equilibrium?
}

Run over 100,000 uniform random starts (and, for contrast, with the stick-breaking sampler below), the tally is:

[2] The experiment — random starts under the replicator dynamic
    "Divide a stack of ten dimes." Which equilibrium does each start reach?

    uniform sampling — 100,000 trials
      fair division 5|5   60.8%  ██████████████████████████████
      polymorphism 4|6    28.1%  ██████████████
      polymorphism 3|7     9.4%  █████
      polymorphism 2|8     1.7%  █
      polymorphism 1|9     0.1%  
      other                0.0%  

    stick-breaking sampling — 100,000 trials
      fair division 5|5   34.2%  █████████████████
      polymorphism 4|6    28.5%  ██████████████
      polymorphism 3|7    19.4%  ██████████
      polymorphism 2|8    11.9%  ██████
      polymorphism 1|9     5.8%  ███
      other                0.2%  

    → fair division is common but not inevitable: 61% of uniform starts,
      only 34% of stick-breaking starts. The rest is polymorphism. (Skyrms 1996.)

Fair division is the single most common outcome — its ~61% under uniform sampling matches Skyrms's classic result — but it is far from inevitable. The polymorphisms are genuine attractors: at the (i, 10−i) split a strict demand earns i, while the equal split trying to invade earns only 5·i/(10−i) < i for i < 5, so it cannot undercut them.

Scaling up: how you sample

How often you see each outcome depends on how the random start is drawn, because different samplers weight different regions of the simplex. The toolkit offers two off the seeded PRNG:

rng.simplex(N)      // uniform on the simplex — concentrates near the centroid
rng.stickBreak(N)   // sparse mixtures — explores the edges, where polymorphisms live

A cautionary detail. Both draws use Random.random() (a real in [0, 1)). An early version of this demo mistakenly used Random.next() — the raw 32-bit integer — which collapsed every "random" start onto the centroid and produced a spurious 100% fair division. When a result looks too clean, suspect the sampler before the model.

Scaling up: the figure

Finally, the publication figure: the two dynamical faces, and the outcome spectrum by sampler. This is where the full program's length comes from — a hand-built SVG, drawn in the toolkit's house style. The simulation underneath is still the few lines above.

Divide the dollar under the replicator dynamic: fair division from a uniform start, the (3,7) polymorphism from Skyrms's start, and the outcome spectrum over 100,000 random starts by sampling scheme.
Top: fair division from a uniform start, and the (3, 7) polymorphism from Skyrms’s fixed start. Bottom: the outcome spectrum over 100,000 random starts — uniform sampling (light) versus stick-breaking (dark), which finds the polymorphisms far more often.
The complete program — divide-the-dollar.mjs (simulation + figure)
#!/usr/bin/env node
/**
 * divide-the-dollar.mjs — Brian Skyrms's "Divide the Dollar", via the Dynamo toolkit.
 *
 * A reproduction of Skyrms's C program (`converge.c`, "Divide a stack of ten
 * dimes"): the Nash demand game on eleven strategies — each player demands a
 * whole number of dimes, 0…10 — evolved under the replicator dynamic.
 *
 *     payoff(demand i against demand j) = i   if i + j ≤ 10   (compatible)
 *                                       = 0   otherwise        (over-demand → nothing)
 *
 * Skyrms's question (Evolution of the Social Contract, 1996): does fair division
 * — everyone demanding half (5|5) — emerge from blind evolutionary dynamics? The
 * answer is a resounding yes: from essentially every interior starting point the
 * replicator dynamic carries the population to the equal split. The complementary
 * "polymorphisms" (a fraction demanding i and the rest demanding 10−i) are
 * equilibria too, but their basins are vanishingly small — reachable only when
 * the equal split is excluded from the start, as in Skyrms's own fixed run.
 *
 * What the toolkit supplies here: `Game.symmetric` builds the demand game and its
 * payoff machinery, and the `replicator` field + `flow`/`trajectory` integrators
 * carry the population. Skyrms wrote a *discrete* replicator with a background
 * fitness `c` (`P[i] ← P[i]·(U[i]+c)/(Ū+c)`); the toolkit's replicator is the
 * continuous-time field `ẋ_i = x_i·(U_i − Ū)`. They share rest points and basins
 * — adding a constant to every payoff leaves the replicator field unchanged — so
 * the picture is the same. Part 1 below runs Skyrms's exact discrete update (so
 * this is a faithful reproduction of his code); Part 2 uses the toolkit's
 * continuous field for the many-trials experiment.
 *
 * Original C: ~/Source/converge.c (Brian Skyrms). Run: `node divide-the-dollar.mjs`.
 */

import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { writeFileSync } from 'node:fs';
import { Game, replicator, flow, trajectory, uniformState, Random } from '../../src/index.js';

const here = dirname(fileURLToPath(import.meta.url));

// ── The game ────────────────────────────────────────────────────────────────
const N = 11;                                   // demands of 0,1,…,10 dimes
const A = Array.from({ length: N }, (_, i) =>
  Array.from({ length: N }, (_, j) => (i + j <= N - 1 ? i : 0))
);
const game = Game.symmetric(A, {
  name: 'Divide-the-dollar',
  labels: [Array.from({ length: N }, (_, i) => String(i))],
});

/** Name the outcome a distribution has settled on. */
function outcome(p) {
  const surv = p.map((v, i) => (v > 0.05 ? i : -1)).filter((i) => i >= 0);
  if (surv.length === 1 && surv[0] === 5) return 'fair division (5 | 5)';
  if (surv.length === 2 && surv[0] + surv[1] === 10) return `polymorphism (${surv[0]} | ${surv[1]})`;
  return `mixture {${surv.join(', ')}}`;
}

/** A compact one-line view of a distribution (a dot for ~zero entries). */
const show = (p) => p.map((v) => (v < 5e-3 ? '   ·  ' : v.toFixed(3))).join(' ');

// ── Part 1 — faithful reproduction: Skyrms's discrete replicator ──────────────
// His exact update, using the toolkit to compute the fitnesses U_i = (A·p)_i.
function skyrmsStep(p, c = 10) {
  const U = game.payoff([p])[0];                       // toolkit: expected payoff of each demand
  const Ubar = p.reduce((s, pi, i) => s + pi * U[i], 0);
  return p.map((pi, i) => (pi * (U[i] + c)) / (Ubar + c));
}
/** Skyrms's convergence test: one demand, or a complementary pair, owns the dollar. */
function converged(p) {
  if (p[5] > 0.9999) return true;
  for (let i = 0; i <= 4; i++) if (p[i] + p[N - 1 - i] > 0.9999) return true;
  return false;
}

function reproduceSkyrms() {
  console.log('[1] Faithful reproduction — Skyrms\'s discrete replicator (c = 10)');
  console.log('    initial mix: demands 1, 3, 7, 9 at 0.25 each (the equal split, 5, is absent)\n');
  let p = Array(N).fill(0);
  p[1] = p[3] = p[7] = p[9] = 0.25;
  const snapshots = new Set([0, 1, 2, 5, 10, 25, 50, 100, 200, 400, 800]);
  let gen = 0;
  console.log('     gen   ' + Array.from({ length: N }, (_, i) => String(i).padStart(5)).join(' '));
  for (; gen < 100000; gen++) {
    if (snapshots.has(gen)) console.log(`    ${String(gen).padStart(5)}   ${show(p)}`);
    if (converged(p)) break;
    p = skyrmsStep(p);
  }
  console.log(`    ${String(gen).padStart(5)}   ${show(p)}`);
  console.log(`\n    → converged after ${gen} generations to ${outcome(p)}.`);
  console.log('      With the equal split absent, fair division is unreachable, so the');
  console.log('      population settles on the complementary pair that exhausts the dollar.\n');
}

// ── Part 2 — the experiment: many random starts under the replicator field ────
// "Divide a stack of ten dimes — N trials": draw a random initial population,
// carry it to rest with the toolkit's continuous replicator, and record which
// equilibrium it reaches. The starts come straight off the toolkit's seeded PRNG:
//   • rng.simplex(N)    — uniform on the simplex; piles up near the centroid
//                         (every demand ≈ 1/11), deep inside fair division's basin.
//   • rng.stickBreak(N) — sparse mixtures on the simplex edges and faces, where
//                         the polymorphic equilibria live, so it finds them far
//                         more often (fair division drops sharply).

function settle(p0, { T = 800, dt = 0.1, chunk = 5 } = {}) {
  let p = [p0];
  for (let t = 0; t < T; t += chunk) {
    p = flow(game, replicator, p, chunk, { dt });
    if (converged(p[0])) break;
  }
  return p[0];
}

// The outcome spectrum: fair division, the four genuine polymorphisms, and a
// catch-all (rare near-vertex starts the flow cannot leave within the horizon).
const SPECTRUM = [
  { key: 'fair', label: 'fair division 5|5', tick: '5|5' },
  { key: 'p46', label: 'polymorphism 4|6', tick: '4|6' },
  { key: 'p37', label: 'polymorphism 3|7', tick: '3|7' },
  { key: 'p28', label: 'polymorphism 2|8', tick: '2|8' },
  { key: 'p19', label: 'polymorphism 1|9', tick: '1|9' },
  { key: 'other', label: 'other', tick: 'other' },
];
function bucket(p) {
  const s = p.map((v, i) => (v > 0.05 ? i : -1)).filter((i) => i >= 0);
  if (s.length === 1 && s[0] === 5) return 'fair';
  if (s.length === 2 && s[0] + s[1] === 10 && s[0] >= 1 && s[0] <= 4) return `p${s[0]}${s[1]}`;
  return 'other';
}

function runExperiment(draw, name, trials, seed) {
  const rng = new Random(seed);
  const count = Object.fromEntries(SPECTRUM.map((c) => [c.key, 0]));
  for (let k = 0; k < trials; k++) count[bucket(settle(draw(rng)))]++;
  const pct = (k) => (100 * count[k]) / trials;
  console.log(`    ${name} sampling — ${trials.toLocaleString()} trials`);
  for (const c of SPECTRUM) {
    const p = pct(c.key);
    console.log(`      ${c.label.padEnd(18)} ${p.toFixed(1).padStart(5)}%  ${'█'.repeat(Math.round(p / 2))}`);
  }
  console.log('');
  return { name, trials, count, pct };
}

// ── The shareable figure ──────────────────────────────────────────────────────
// Two stories side by side: fair division from a uniform start, and Skyrms's
// polymorphism from his fixed start. Hand-built SVG in the toolkit's house style.
const PAL = { paper: '#faf9f7', ink: '#2c2c2c', ink2: '#6b6b6b', accent: '#8b7355', grid: '#e6e1d8', faint: '#cdc6ba' };
const SURV = ['#8b7355', '#4a5a6b', '#8b4a4a', '#4a6b5a', '#b8965a'];

function series(p0, tMax) {
  const tr = trajectory(game, replicator, [p0], { tMax, dt: 0.05 });
  const samples = tr.states.filter((_, i) => i % 4 === 0 || i === tr.states.length - 1);
  const final = tr.states[tr.states.length - 1][0];
  return { rows: samples.map((s) => s[0]), tMax, final };
}

function panel(x0, y0, w, h, data, title) {
  const padL = 44, padB = 30, padT = 30, padR = 64;
  const px = x0 + padL, py = y0 + padT, pw = w - padL - padR, ph = h - padT - padB;
  const X = (t) => px + (t / (data.rows.length - 1)) * pw;
  const Y = (v) => py + (1 - v) * ph;
  const out = [];
  out.push(`<text x="${x0 + padL}" y="${y0 + 18}" font-family="'Crimson Pro',Georgia,serif" font-size="17" fill="${PAL.ink}">${title}</text>`);
  // axes + gridlines
  for (let g = 0; g <= 1; g += 0.25) {
    out.push(`<line x1="${px}" y1="${Y(g).toFixed(1)}" x2="${px + pw}" y2="${Y(g).toFixed(1)}" stroke="${PAL.grid}" stroke-width="1"/>`);
    out.push(`<text x="${px - 8}" y="${(Y(g) + 3.5).toFixed(1)}" text-anchor="end" font-family="'JetBrains Mono',monospace" font-size="10" fill="${PAL.ink2}">${g.toFixed(2)}</text>`);
  }
  out.push(`<line x1="${px}" y1="${py}" x2="${px}" y2="${py + ph}" stroke="${PAL.ink}" stroke-width="1.2"/>`);
  out.push(`<line x1="${px}" y1="${py + ph}" x2="${px + pw}" y2="${py + ph}" stroke="${PAL.ink}" stroke-width="1.2"/>`);
  out.push(`<text x="${px + pw / 2}" y="${y0 + h - 4}" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-size="10" fill="${PAL.ink2}">time →</text>`);
  // one line per demand; dying demands faint, survivors strong + labelled
  const survivors = data.final.map((v, i) => (v > 0.05 ? i : -1)).filter((i) => i >= 0);
  let ci = 0;
  for (let i = 0; i < N; i++) {
    const isSurv = survivors.includes(i);
    const col = isSurv ? SURV[ci++ % SURV.length] : PAL.faint;
    const d = data.rows.map((r, k) => `${k === 0 ? 'M' : 'L'}${X(k).toFixed(1)},${Y(r[i]).toFixed(1)}`).join('');
    out.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="${isSurv ? 2.4 : 1}" opacity="${isSurv ? 1 : 0.6}"/>`);
    if (isSurv) {
      const yEnd = Y(data.final[i]);
      out.push(`<text x="${px + pw + 6}" y="${(yEnd + 3.5).toFixed(1)}" font-family="'JetBrains Mono',monospace" font-size="11" fill="${col}">demand ${i}</text>`);
    }
  }
  return out.join('');
}

/** Grouped bars: the outcome spectrum for the two sampling methods. */
function barPanel(x0, y0, w, h, uniform, stick, title) {
  const padL = 42, padB = 30, padT = 30, padR = 16;
  const px = x0 + padL, py = y0 + padT, pw = w - padL - padR, ph = h - padT - padB;
  const top = 70;                                   // % axis maximum
  const Y = (v) => py + (1 - v / top) * ph;
  const out = [];
  out.push(`<text x="${px}" y="${y0 + 18}" font-family="'Crimson Pro',Georgia,serif" font-size="17" fill="${PAL.ink}">${title}</text>`);
  for (let g = 0; g <= top; g += 10) {
    out.push(`<line x1="${px}" y1="${Y(g).toFixed(1)}" x2="${px + pw}" y2="${Y(g).toFixed(1)}" stroke="${PAL.grid}" stroke-width="1"/>`);
    out.push(`<text x="${px - 7}" y="${(Y(g) + 3.5).toFixed(1)}" text-anchor="end" font-family="'JetBrains Mono',monospace" font-size="10" fill="${PAL.ink2}">${g}%</text>`);
  }
  out.push(`<line x1="${px}" y1="${(py + ph).toFixed(1)}" x2="${px + pw}" y2="${(py + ph).toFixed(1)}" stroke="${PAL.ink}" stroke-width="1.2"/>`);
  const slot = pw / SPECTRUM.length, bw = slot * 0.30;
  SPECTRUM.forEach((c, i) => {
    const cx = px + slot * (i + 0.5);
    const bar = (bx, val, col) =>
      `<rect x="${bx.toFixed(1)}" y="${Y(val).toFixed(1)}" width="${bw.toFixed(1)}" height="${(py + ph - Y(val)).toFixed(1)}" fill="${col}"/>` +
      `<text x="${(bx + bw / 2).toFixed(1)}" y="${(Y(val) - 4).toFixed(1)}" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-size="9.5" fill="${PAL.ink2}">${val.toFixed(0)}</text>`;
    out.push(bar(cx - bw - 2, uniform.pct(c.key), PAL.faint));
    out.push(bar(cx + 2, stick.pct(c.key), PAL.accent));
    out.push(`<text x="${cx.toFixed(1)}" y="${(py + ph + 16).toFixed(1)}" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-size="11.5" fill="${PAL.ink}">${c.tick}</text>`);
  });
  const lx = px + pw - 168, ly = py + 2;
  out.push(`<rect x="${lx}" y="${ly}" width="11" height="11" fill="${PAL.faint}"/><text x="${lx + 16}" y="${ly + 10}" font-family="'JetBrains Mono',monospace" font-size="11" fill="${PAL.ink2}">uniform</text>`);
  out.push(`<rect x="${lx + 84}" y="${ly}" width="11" height="11" fill="${PAL.accent}"/><text x="${lx + 100}" y="${ly + 10}" font-family="'JetBrains Mono',monospace" font-size="11" fill="${PAL.ink2}">stick-break</text>`);
  return out.join('');
}

function writeFigure(uniformData, skyrmsData, uniform, stick) {
  const W = 980, H = 712, pw = 460, ph = 250, top = 64;
  const cap = 'Fair division is common but not inevitable — the polymorphisms (i | 10−i) take the rest, and stick-breaking finds them far more often.';
  const svg =
    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="'Crimson Pro',Georgia,serif">` +
    `<style>@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600&amp;family=JetBrains+Mono&amp;display=swap');</style>` +
    `<rect width="${W}" height="${H}" fill="${PAL.paper}"/>` +
    `<text x="${W / 2}" y="30" text-anchor="middle" font-size="22" font-weight="600" fill="${PAL.ink}">Divide the Dollar — the replicator dynamic</text>` +
    `<text x="${W / 2}" y="50" text-anchor="middle" font-size="13" fill="${PAL.ink2}">After Brian Skyrms · reproduced with the Dynamo toolkit</text>` +
    panel(20, top, pw, ph, uniformData, 'A uniform start → fair division') +
    panel(W - pw - 20, top, pw, ph, skyrmsData, 'Skyrms’s start {1,3,7,9} → polymorphism') +
    barPanel(20, top + ph + 30, W - 40, 270, uniform, stick, `Outcomes of ${uniform.trials.toLocaleString()} random starts`) +
    `<text x="${W / 2}" y="${H - 14}" text-anchor="middle" font-size="13.5" fill="${PAL.ink}">${cap}</text>` +
    `</svg>`;
  const file = join(here, 'divide-the-dollar.svg');
  writeFileSync(file, svg);
  return file;
}

// ── Main ──────────────────────────────────────────────────────────────────────
const trials = Number(process.argv[2]) || 2000;
const seed = Number(process.argv[3]) || 20260607;

console.log('Skyrms — Divide the Dollar (ten dimes), via the Dynamo toolkit');
console.log('='.repeat(62));
console.log('Nash demand game · 11 strategies (demand 0…10)');
console.log('payoff(i vs j) = i if i + j ≤ 10, else 0  (over-demand → nothing)\n');

reproduceSkyrms();

console.log('[2] The experiment — random starts under the replicator dynamic');
console.log('    "Divide a stack of ten dimes." Which equilibrium does each start reach?\n');
const uniform = runExperiment((rng) => rng.simplex(N), 'uniform', trials, seed);
const stick = runExperiment((rng) => rng.stickBreak(N), 'stick-breaking', trials, seed + 1);
console.log(`    → fair division is common but not inevitable: ${uniform.pct('fair').toFixed(0)}% of uniform starts,`);
console.log(`      only ${stick.pct('fair').toFixed(0)}% of stick-breaking starts. The rest is polymorphism. (Skyrms 1996.)\n`);

const file = writeFigure(series(uniformState([N])[0], 18), series(skyrmsIC(), 28), uniform, stick);
console.log(`Wrote figure → ${file}`);

function skyrmsIC() { const p = Array(N).fill(0); p[1] = p[3] = p[7] = p[9] = 0.25; return p; }

References