Guides & tutorials
Getting started
Dynamo.js is a zero-dependency JavaScript library for drawing the phase diagrams of evolutionary game dynamics. It is a faithful port of William H. Sandholm's Dynamo Mathematica suite, and it does in the browser, or in Node, what those notebooks did in Mathematica: given a population game and one of fourteen evolutionary dynamics, it integrates the flow over the relevant simplex (or product of simplices) and renders a publication-quality phase portrait as an SVG — vector field, trajectories, rest points coloured by stability, the frame and the strategy labels.
There is nothing to install and nothing to compile. The whole library is a few small ES modules and one bundled file. You can load it in two ways.
Way one — the single bundle (a Dynamo global)
The simplest path. Drop in the pre-built bundle and the entire API hangs off
a single global named Dynamo. No build step, no module loader, and
— because it is a plain classic script rather than an ES module — it works even
when the page is opened straight from disk over file://.
<script src="dist/dynamo.js"></script>
<script>
const { standardGames, replicator, phasePortraitSVG } = Dynamo;
const svg = phasePortraitSVG(standardGames.hawkDove(), replicator, {
title: 'Hawk–Dove'
});
document.getElementById('portrait').innerHTML = svg;
</script>
<div id="portrait"></div>
Everything the library exposes is a property of Dynamo: the
games (Dynamo.standardGames, Dynamo.gameCatalog,
Dynamo.Game), the dynamics (Dynamo.replicator,
Dynamo.logit, Dynamo.bnn, …), the renderers
(Dynamo.phasePortraitSVG,
Dynamo.phasePortrait3dSVG), the numerics
(Dynamo.trajectory, Dynamo.restPoints,
Dynamo.classify) and the seedable PRNG
(Dynamo.setSeed). If you prefer not to destructure, write
Dynamo.replicator and friends in full.
Way two — ES modules
If you are already working with native modules, import only the pieces you
need straight from source. The engine — games, dynamics, integration, rest
points and stability — is re-exported from the barrel
src/index.js:
import { standardGames, replicator, trajectory,
restPoints, classify, setSeed, Game } from './src/index.js';
The two SVG renderers live in their own modules — the engine deliberately owns no rendering, so the barrel does not re-export them. Import the portrait functions directly:
import { phasePortraitSVG } from './src/render-2d.js'; // square, triangle
import { phasePortrait3dSVG } from './src/render-3d.js'; // tetrahedron, cube, prism
A note on serving. Native ES modules cannot be loaded over
file:// — the browser's module loader refuses cross-origin
import from the local filesystem. During development you therefore
need a local HTTP server. Any will do; for example, from the library's
directory:
python3 -m http.server 8000
# then open http://localhost:8000/your-page.html
The bundle (Way one) has no such requirement, which is exactly why it
exists: it is the path for a page that must open with a double-click, and the
path used by the offline showcase.html.
A complete first example
Here is a self-contained page. It builds the Hawk–Dove game, renders the replicator dynamic's phase portrait, and inserts it into the document — about a dozen lines, start to finish.
<!doctype html>
<html>
<body>
<h1>My first phase portrait</h1>
<div id="portrait"></div>
<script src="dist/dynamo.js"></script>
<script>
const { standardGames, replicator, phasePortraitSVG } = Dynamo;
const game = standardGames.hawkDove(); // a 2×2 bimatrix game
const svg = phasePortraitSVG(game, replicator, {
width: 480, height: 480, title: 'Hawk–Dove (replicator)'
});
document.getElementById('portrait').innerHTML = svg;
</script>
</body>
</html>
Open it and you will see the unit square with Hawk and Dove on each axis, a faint vector field of arrows, a sheaf of trajectories curving toward the two off-diagonal corners, and the rest points drawn as glyphs: filled discs at the two sinks, a ringed dot at the interior saddle. That single call did all the work — there was no loop to write, no canvas to manage.
The same idea in Node
The library renders SVG as a string, which makes it just as happy
outside the browser: you can generate diagrams headlessly, in a build script or
a test, and write them straight to disk. The bundle is a CommonJS-compatible
file that installs the API as the global Dynamo when it is
required. Read it back off globalThis.Dynamo:
// node make-portrait.js
require('./dist/dynamo.js'); // installs globalThis.Dynamo
const { standardGames, replicator, phasePortraitSVG } = globalThis.Dynamo;
const svg = phasePortraitSVG(standardGames.hawkDove(), replicator, {
title: 'Hawk–Dove'
});
require('fs').writeFileSync('hawk-dove.svg', svg);
console.log('wrote hawk-dove.svg —', svg.length, 'bytes');
If you are running native ES modules in Node instead (the package sets
"type": "module"), import from source exactly as in the browser —
import { standardGames, replicator } from './src/index.js' and the
renderers from ./src/render-2d.js — and the rendered string is
identical. That a portrait is a pure string, computed the same way in Node and
in the browser, is a deliberate discipline: the print and export path is the
primary path, and the interactive page merely layers behaviour on top of it.
The five shapes
Sandholm's original suite was five notebooks — 2x2,
3S, 4S, 3x2 and 2x2x2. The
decisive observation behind this port is that those five share one
identical engine: the same dynamics, the same payoff machinery, the same
integrator and the same stability tests. They differ only in geometry
— in the shape of the picture. Dynamo.js is therefore one general engine plus
five per-geometry renderers, not five separate ports.
A game's geometry is fixed by its dims — the array giving the
number of strategies in each population. You never choose a shape by hand: the
renderer reads game.dims and resolves the right geometry
automatically (the mapping lives in geometryFor). The five cases
are:
dims | populations × strategies | space | shape | notebook |
|---|---|---|---|---|
[2,2] | 2 × 2 | Δ¹ × Δ¹ | unit square (2-D) | 2x2 |
[3] | 1 × 3 | Δ² | triangle (2-D) | 3S |
[4] | 1 × 4 | Δ³ | tetrahedron (3-D) | 4S |
[3,2] | 2 × {3, 2} | Δ² × Δ¹ | triangular prism (3-D) | 3x2 |
[2,2,2] | 3 × 2 | (Δ¹)³ | cube (3-D) | 2x2x2 |
Two consequences follow, and they are worth stating plainly because they govern how the library is used.
- A game's
dimsfix its geometry, so games are not interchangeable across shapes. A 2×2 bimatrix game is a square; a three-strategy game is a triangle. You cannot draw Rock–Paper–Scissors on the cube, because it is a one-population, three-strategy game and the cube is the home of three two-strategy populations. The catalogue (gameCatalog) groups the preset games by the shape they live on for exactly this reason. - The dynamics, by contrast, are universal. Every one of
the fourteen dynamics — replicator, logit, BNN, Smith, projection and the
rest — is a pure function
V(state, game) → velocitythat runs unchanged on every shape. Once you have a valid game, any dynamic will flow on it.
The pattern, then, is always the same: pick a shape, pick a game valid for
it, pick any dynamic, and render. The 2-D shapes (square, triangle) go through
phasePortraitSVG; the 3-D shapes (tetrahedron, prism, cube) go
through phasePortrait3dSVG, which draws a rotatable orthographic
projection. Each renderer checks the geometry and will tell you if you have
reached for the wrong one.
Square — dims [2,2] (the 2x2 notebook)
The state is a pair of mixed strategies, one per population:
[[x₁, x₂], [y₁, y₂]], each row summing to one. Because each
population has only two strategies, the whole state is pinned down by
(x₁, y₁) — the share playing the first strategy in each
population. The picture is the unit square, with population 1
along the horizontal axis and population 2 up the vertical. Preset
games that live here: prisonersDilemma,
coordination, stagHunt, hawkDove and
matchingPennies — the classic bimatrix menagerie. Coordination and
Stag Hunt show two corner sinks separated by a saddle; the Prisoner's Dilemma
collapses to its single mutual-defection sink; Matching Pennies, being
zero-sum, gives a centre of closed replicator orbits.
import { standardGames, replicator } from './src/index.js';
import { phasePortraitSVG } from './src/render-2d.js';
const game = standardGames.stagHunt(); // dims [2,2] → the square
const svg = phasePortraitSVG(game, replicator, { title: 'Stag Hunt' });
document.getElementById('chart').innerHTML = svg;
Triangle — dims [3] (3S)
The state is a single mixed strategy over three pure
strategies, [[p₁, p₂, p₃]] with p₁ + p₂ + p₃ = 1 — a
point of the 2-simplex. The picture is an equilateral
triangle, one vertex per pure strategy, with the centre being the uniform
mixture (⅓, ⅓, ⅓). Preset games:
rockPaperScissors, goodRPS, badRPS,
coordination3, zeeman (Zeeman's game) and
young (Young's game). This is the richest 2-D shape: cycles,
spirals, limit cycles and interior attractors all live here.
import { standardGames, replicator } from './src/index.js';
import { phasePortraitSVG } from './src/render-2d.js';
const game = standardGames.rockPaperScissors(); // dims [3] → the triangle
const svg = phasePortraitSVG(game, replicator, { title: 'Rock–Paper–Scissors' });
document.getElementById('chart').innerHTML = svg;
Tetrahedron — dims [4] (4S)
The state is one mixed strategy over four pure strategies,
[[p₁, p₂, p₃, p₄]] summing to one — a point of the 3-simplex.
The picture is a regular tetrahedron, one vertex per pure
strategy, rendered as a rotatable, depth-sorted, translucent solid.
Preset games: cyclic4 (a four-strategy cycle, each
strategy beating the next) and coordination4 (four pure sinks, one
at each vertex). Because the space is now three-dimensional, render with the
3-D function:
import { standardGames, replicator } from './src/index.js';
import { phasePortrait3dSVG } from './src/render-3d.js';
const game = standardGames.cyclic4(); // dims [4] → the tetrahedron
const svg = phasePortrait3dSVG(game, replicator, {
title: 'Four-strategy cycle',
azimuth: 0.6, elevation: 0.34 // camera angles, in radians
});
document.getElementById('chart').innerHTML = svg;
Triangular prism — dims [3,2] (3x2)
The state is a pair of mixed strategies for two populations
of unequal size: a three-strategy population and a two-strategy one,
[[p₁, p₂, p₃], [q₁, q₂]], each row summing to one. The
picture is a triangular prism — the first population's triangle swept
along the second population's interval, with the two-strategy population
choosing the height between the base and the top triangular face.
Preset game: outsideOption — coordination with an
outside option, the notebook's 3×2 example. A 3-D shape, so:
import { standardGames, replicator } from './src/index.js';
import { phasePortrait3dSVG } from './src/render-3d.js';
const game = standardGames.outsideOption(); // dims [3,2] → the prism
const svg = phasePortrait3dSVG(game, replicator, {
title: 'Coordination with outside option'
});
document.getElementById('chart').innerHTML = svg;
Cube — dims [2,2,2] (2x2x2)
The state is three mixed strategies, one per population,
each over two strategies: [[x₁, x₂], [y₁, y₂], [z₁, z₂]]. As with
the square, each population is pinned by the share playing its first strategy,
so the state is a point (x₁, y₁, z₁) of the unit cube. The
picture is that cube, with one axis per population. The payoffs here
are genuinely three-body — each population's payoff is a trilinear form
in the other two populations' states — which is what makes this the most
intricate of the five. Preset games:
threeWayCoordination (each population rewarded for matching the
others) and threeWayAnticoordination (each rewarded for
differing). A 3-D shape:
import { standardGames, replicator } from './src/index.js';
import { phasePortrait3dSVG } from './src/render-3d.js';
const game = standardGames.threeWayCoordination(); // dims [2,2,2] → the cube
const svg = phasePortrait3dSVG(game, replicator, {
title: 'Three-way coordination'
});
document.getElementById('chart').innerHTML = svg;
One small honesty about the 3-D shapes: rest points are drawn where the set of them is small enough to read; for games with a degenerate manifold of rest points the renderer suppresses the dots rather than smother the picture in a cloud of glyphs. The flow itself always renders. The crisp, fully-classified rest-point picture is the natural domain of the 2-D shapes, and that is where the rest-point tutorial below works.
Tutorials
Each of the following is a complete, runnable example using the real API,
with a word on what you should see. They are written with the ES-module imports;
to run any of them against the bundle instead, drop the
import lines and read the same names off the Dynamo
global. The same SVG comes out either way.
1. The Hawk–Dove portrait
The canonical anti-coordination game. Each population plays Hawk or Dove; two Hawks fight and do badly, a Hawk against a Dove wins outright, two Doves share. In the two-population replicator dynamic this produces the textbook picture: the two off-diagonal corners — where the populations play opposite strategies — are sinks, and the symmetric interior mixture is a saddle. Watch the trajectories peel away from the diagonal toward one corner or the other.
import { standardGames, replicator } from './src/index.js';
import { phasePortraitSVG } from './src/render-2d.js';
const hd = standardGames.hawkDove();
const svg = phasePortraitSVG(hd, replicator, {
width: 480, height: 480, title: 'Hawk–Dove (replicator)'
});
document.getElementById('chart').innerHTML = svg;
The rest points are drawn for you, coloured by stability: two filled discs at the corner sinks, a ringed dot at the interior saddle (and hollow circles at the two unstable corners). If you want the numbers behind the glyphs, see tutorial 4. Note the dynamical subtlety worth savouring: that interior mixture is the game's evolutionarily stable strategy, yet under the two-population replicator dynamic it appears as a saddle rather than a sink — a well-known and slightly surprising fact that the portrait makes vivid.
2. The Rock–Paper–Scissors cycle, and Good vs Bad RPS
Move to the triangle. Standard Rock–Paper–Scissors is a zero-sum cycle: Rock
beats Scissors beats Paper beats Rock, symmetrically. Under the replicator
dynamic the interior equilibrium (⅓, ⅓, ⅓) is a centre —
the orbits are closed loops that neither spiral in nor out, circling the centre
forever. (The renderer marks a centre with a small diamond.)
import { standardGames, replicator } from './src/index.js';
import { phasePortraitSVG } from './src/render-2d.js';
const rps = standardGames.rockPaperScissors();
document.getElementById('rps').innerHTML =
phasePortraitSVG(rps, replicator, { title: 'Rock–Paper–Scissors (a centre)' });
Now perturb the payoffs. Good RPS tilts the cycle so that winning pays more than losing costs; the net effect is dissipative and the centre becomes a spiral sink — orbits spiral inward to the equilibrium. Bad RPS tilts it the other way; the centre becomes a spiral source and orbits spiral outward toward the boundary. Rendering the three side by side is the cleanest way to feel how a small change in payoffs flips the qualitative dynamics.
const good = standardGames.goodRPS(); // spiral sink — orbits spiral in
const bad = standardGames.badRPS(); // spiral source — orbits spiral out
document.getElementById('good').innerHTML =
phasePortraitSVG(good, replicator, { title: 'Good RPS (spiral sink)' });
document.getElementById('bad').innerHTML =
phasePortraitSVG(bad, replicator, { title: 'Bad RPS (spiral source)' });
3. Best response versus logit — the η noise parameter
The logit dynamic is a smoothed best response: each population drifts toward
the choice that a noisy best-responder would make, where the noise level is set
by a temperature η. As η → 0 the smoothing vanishes and logit converges to the
sharp best-response dynamic. Indeed Dynamo defines best response as the
sharp limit — br is logit(0.001) internally — so the
pair lets you see the same dynamic at two ends of a knob.
logit is a factory: call it with η to get a dynamic.
br is a ready-made dynamic, used directly. Render Hawk–Dove (or any
game) under several temperatures and the best-response field, and watch the flow
sharpen as η shrinks:
import { standardGames, logit, br } from './src/index.js';
import { phasePortraitSVG } from './src/render-2d.js';
const g = standardGames.hawkDove();
document.getElementById('hot').innerHTML =
phasePortraitSVG(g, logit(0.30), { title: 'Logit, η = 0.30 (noisy)' });
document.getElementById('warm').innerHTML =
phasePortraitSVG(g, logit(0.08), { title: 'Logit, η = 0.08' });
document.getElementById('sharp').innerHTML =
phasePortraitSVG(g, br, { title: 'Best response (η → 0)' });
At a high temperature the field is gentle and the interior rest point sits
well inside the square; as η falls, the flow stiffens and the picture approaches
the kinked best-response portrait. A subtlety the library handles for you:
logit is a smooth dynamic, so its rest points are classified by the
eigenvalues of the Jacobian, whereas br is kinked, so it is
classified by a kink-robust radial-contraction test instead. You do not choose
the test — classify reads each dynamic's characterisation and picks
the right one.
4. Find and classify rest points
To work with the rest points as data rather than glyphs, call
restPoints(game, dynamic) — it returns the zeros of the vector
field, each as { reduced, state } — and classify each one with
classify(game, dynamic, state), which returns its
type ('sink', 'source',
'saddle', 'centre', 'spiral sink',
'spiral source') and the method used. Here we tally
Hawk–Dove's rest points by kind:
import { standardGames, replicator, restPoints, classify } from './src/index.js';
const hd = standardGames.hawkDove();
const pts = restPoints(hd, replicator);
const tally = { sink: [], source: [], saddle: [], centre: [], other: [] };
for (const rp of pts) {
const { type } = classify(hd, replicator, rp.state);
const where = '(' + rp.reduced.map(v => v.toFixed(2)).join(', ') + ')';
(tally[type] ?? tally.other).push(where);
}
console.log('sinks :', tally.sink.join(' ')); // the two off-diagonal corners
console.log('sources:', tally.source.join(' ')); // the two diagonal corners
console.log('saddles:', tally.saddle.join(' ')); // the interior mixture (0.50, 0.50)
For Hawk–Dove this finds five rest points: two corner sinks, two corner
sources, and the interior saddle at (0.50, 0.50) — precisely the
structure the portrait drew. There is a convenience too:
classifyAll(game, dynamic, points) returns the same list with a
.stability field attached to each point, if you would rather
classify in one pass:
import { classifyAll } from './src/index.js';
for (const rp of classifyAll(hd, replicator, pts)) {
console.log(rp.reduced.map(v => v.toFixed(2)), '→', rp.stability.type);
}
5. Integrate a single trajectory
Sometimes you want one orbit, not the whole sheaf — to follow a particular
initial condition, measure where it ends up, or feed its path to your own
plotting. trajectory(game, dynamic, x0, opts) integrates ẋ = V(x)
from the starting state x0 and returns three parallel arrays:
times, the full structured states at each step, and
points — the reduced plotting coordinates (the first-strategy share
of each population) ready to drop onto an axis.
import { standardGames, replicator, trajectory } from './src/index.js';
const hd = standardGames.hawkDove();
// start near the diagonal and watch which corner wins
const path = trajectory(hd, replicator, [[0.7, 0.3], [0.4, 0.6]], {
tMax: 20, // how far to integrate, in dynamical time
dt: 0.05 // step size (RK4 is the default integrator)
});
console.log('steps recorded :', path.points.length);
console.log('started at :', path.points[0]); // [0.70, 0.40]
console.log('settled near :', path.points.at(-1)); // ≈ [1.00, 0.00] — a corner sink
// path.points is an array of [x₁, y₁] pairs — plot it however you like
for (const [x1, y1] of path.points) { /* ... */ }
The options mirror Sandholm's use of NDSolve: tMax
and dt set the horizon and step; method: 'rk45'
switches from the default fixed-step RK4 to an adaptive Dormand–Prince
integrator with error control (useful for the sharp dynamics like
logit(0.001)); direction: -1 integrates backward in
time, which is the trick for tracing the stable manifold of a saddle. The flow
is tangent to the simplex by construction, and the integrator re-projects each
step to wipe out numerical drift, so the orbit stays on the simplex exactly.
6. Speed heat maps (Sandholm-style)
Two of Dynamo's most striking diagnostics colour the picture by speed — the magnitude ‖V(x)‖ of the vector field. Where the flow is fast, change is rapid; where it is slow, the system lingers, and the slow regions cluster around rest points and along the ghosts of would-be equilibria. Dynamo.js offers both of Sandholm's forms, switched on by a render option.
The first is a background speed field: pass
{ contour: true } and the renderer samples ‖V‖ across the shape and
tints the backdrop, pale where the flow is slow and deep ember where it is
fast.
import { standardGames, replicator } from './src/index.js';
import { phasePortraitSVG } from './src/render-2d.js';
const rps = standardGames.rockPaperScissors();
document.getElementById('field').innerHTML =
phasePortraitSVG(rps, replicator, {
contour: true, // shade the background by speed ‖V‖
title: 'RPS — speed field'
});
The second is speed-tinted flow: pass
{ speedColour: true } and the trajectories themselves are coloured
by their local speed, so a single orbit reddens where it races and pales where
it crawls — you read acceleration directly off the line.
document.getElementById('flow').innerHTML =
phasePortraitSVG(rps, replicator, {
speedColour: true, // colour the trajectories by their own speed
title: 'RPS — speed-tinted flow'
});
Reading the colours. Both forms use the warm
'ember' scale and the picture carries a small slow → fast
legend. The scale is normalised per diagram: the fastest sampled speed is the
deepest colour and the slowest is the palest, so the tints are relative within a
single portrait, not an absolute velocity. On the closed orbits of
Rock–Paper–Scissors the speed field forms concentric rings — fast far from the
centre, slow near it — and on Hawk–Dove the slow pales gather around the corner
sinks and the interior saddle. The two options compose with everything else and
with each other; the underlying colour helpers,
heatColor(t) and scaleStops(name), are exported too if
you want to build a matching legend of your own. Both work just as well in the
3-D renderer.
7. Export a publication-quality SVG and save it
Because every portrait is an SVG string, exporting is nothing more than writing that string to a file — vector, resolution-independent, and ready for LaTeX, a paper, or further editing in a vector tool. There is no separate export call; the rendered value is already the artefact. In Node:
// node export-figure.js
require('./dist/dynamo.js');
const { standardGames, replicator, phasePortraitSVG } = globalThis.Dynamo;
const svg = phasePortraitSVG(standardGames.badRPS(), replicator, {
width: 600, height: 600,
contour: true,
title: 'Bad RPS'
});
require('fs').writeFileSync('bad-rps.svg', svg);
console.log('wrote bad-rps.svg —', svg.length, 'bytes');
In the browser the same string can be offered as a download with a Blob:
const svg = phasePortraitSVG(standardGames.badRPS(), replicator, { width: 600, height: 600 });
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bad-rps.svg';
a.click();
URL.revokeObjectURL(url);
Render at whatever width/height suits the page: SVG
is resolution-independent, so a figure drawn at 600 px scales cleanly to any
size in print. For finer figures you can also turn the sampling up —
vectorGrid for the density of field arrows, trajGrid
for the number of seeded trajectories, contourRes for the
resolution of the speed field — trading a little render time for detail.
8. Reproducibility — setSeed and why it matters
Most of Dynamo's dynamics are deterministic vector fields and need no
randomness at all; the same game and dynamic give the same portrait every time.
But a few are stochastic in flavour — most notably the sample best-response
dynamic — and a scholarly diagram must be reproducible: the same seed must
always produce the same picture. Every source of randomness in the library
therefore flows through one seedable generator, and never through
Math.random(). You seed it once, at the start, with
setSeed — an integer, or a memorable string:
import { setSeed, standardGames, replicator } from './src/index.js';
import { phasePortraitSVG } from './src/render-2d.js';
setSeed(20240601); // an integer seed …
setSeed('hawk-dove'); // … or a memorable string — either reseeds the shared PRNG
const svg = phasePortraitSVG(standardGames.hawkDove(), replicator);
document.getElementById('chart').innerHTML = svg;
From the bundle the call is Dynamo.setSeed(…). Seeding before
you render (or before you integrate a stochastic-flavoured trajectory)
guarantees that the figure in your draft today is the figure that regenerates
next year — which is the whole point of a reproducible, seeded simulation, and a
discipline this port inherits deliberately. For an independent stream that does
not touch the shared generator, construct your own new Random(seed)
instead.
In memory of William H. Sandholm
Dynamo.js exists because of, and in tribute to, the original Dynamo: a suite of Mathematica notebooks for drawing the phase diagrams of evolutionary game dynamics, created by William H. Sandholm with Emin Dokumacı and Francisco Franchetti at the University of Wisconsin–Madison. Sandholm gave that software away freely from his university page, alongside his magisterial textbook Population Games and Evolutionary Dynamics (MIT Press, 2010) — the book against which every formula in this port has been cross-checked. Generations of students and researchers learned to see evolutionary dynamics through those pictures.
If you use this library in scholarly work, please cite the original suite:
Sandholm, W. H., Dokumacı, E. & Franchetti, F.
Dynamo: Diagrams for Evolutionary Game Dynamics.
University of Wisconsin–Madison.
This JavaScript port is a faithful re-implementation of that work, built in Sandholm's memory. It claims no originality over the mathematics, which is his and his collaborators'; its only ambitions are to be exact to the notebooks, to run anywhere a browser or Node does, to depend on nothing, and to keep the diagrams as legible and as carefully made as the originals were. That the five notebooks turned out to share a single engine — that the 2×2 square, the triangle, the tetrahedron, the prism and the cube are one piece of mathematics wearing five geometries — is itself a small monument to how cleanly the original was conceived. We offer this port in that spirit: with gratitude, and with care.
The mathematics
The mathematics of evolutionary dynamics
Dynamo.js is a faithful, zero-dependency JavaScript port of William H. Sandholm's Dynamo Mathematica suite (built with Emin Dokumacı and Francisco Franchetti), the canonical tool for drawing phase diagrams of evolutionary game dynamics. This section is the conceptual reference for the engine: it explains what a population game is, what a state is, what the four payoff quantities mean, and then gives — for every one of the fourteen dynamics in the library — its exact formula, the behavioural rule it models, its rest-point and stability character, and a small runnable example.
Throughout, code examples use the bundled Dynamo global (load
dist/dynamo.js with a plain <script> tag, or
open the showcase, and every export named below is a property of
Dynamo). The same names are available as ES-module exports from
src/index.js. The maths is exactly that of Sandholm's
Population Games and Evolutionary Dynamics (MIT Press, 2010); each
formula below was transcribed from the notebook and cross-checked against the
book.
1. Population games and the simplex
A population game describes one or more large populations of agents, each agent committed to one of finitely many pure strategies. Rather than track individual agents, we track the distribution of strategies in each population — its mixed strategy, a vector of strategy shares summing to one. The collection of these per-population mixed strategies is the population state.
A game is fixed by its dims — the list of strategy counts, one
per population. Every geometry in Dynamo is just a particular choice of
dims:
dims | populations × strategies | state shape | drawn as |
|---|---|---|---|
[2, 2] | 2 × 2 | [[x₁,x₂], [y₁,y₂]] | unit square |
[3] | 1 × 3 | [[x₁,x₂,x₃]] | triangle (2-simplex) |
[4] | 1 × 4 | [[x₁,x₂,x₃,x₄]] | tetrahedron (3-simplex) |
[3, 2] | 2 × {3, 2} | [[x₁,x₂,x₃], [y₁,y₂]] | triangular prism |
[2, 2, 2] | 3 × 2 | [[x₁,x₂], [y₁,y₂], [z₁,z₂]] | cube |
Each population's mixed strategy lives on a simplex — the set of non-negative vectors summing to one. A population with n strategies occupies the (n−1)-simplex Δⁿ⁻¹. The full population state therefore lives on a product of simplices: one factor per population. This product is what the renderers draw (a square is Δ¹ × Δ¹, a triangle is a single Δ², and so on).
Full, reduced and flat representations
The library carries a state in three interchangeable forms, with explicit
maps between them (in state.js):
-
Full state — every probability, one row per population,
each row summing to one:
[[0.7, 0.3], [0.4, 0.6]]. This is what the dynamics and the payoff field actually evaluate, so the maths matches the notebook exactly. -
Reduced state — drop the last coordinate of each
population (it is determined as
1 − Σ(the rest)). Fordims = [2, 2]this is the pair[x₁, y₁]. The reduced state is the genuineΣₚ(nₚ − 1)-dimensional point that is integrated, root-found and plotted on the unit square (or cube). UsefullToReduced/reducedToFull; both are general overdims. -
Flat state — the full state flattened to a single vector,
[x₁, x₂, y₁, y₂]. The integrator works on this form. SeeflattenState/unflattenState.
const x = [[0.7, 0.3], [0.4, 0.6]];
Dynamo.fullToReduced(x); // → [0.7, 0.4]
Dynamo.reducedToFull([0.7, 0.4]); // → [[0.7, 0.3], [0.4, 0.6]]
Dynamo.uniformState([2, 2]); // → [[0.5, 0.5], [0.5, 0.5]] (the barycentre)
Dynamo.uniformState([3]); // → [[0.333…, 0.333…, 0.333…]]
The payoff field F(x)
The behavioural content of a game is its payoff field
F: given a population state x, F(x) returns the
payoff earned by each strategy of each population, in the same shape
as the state. So F(x)[p][j] is the payoff to strategy
j of population p when the populations are distributed
according to x. A Game is, at heart, exactly this
field; everything else is derived from it.
Dynamo supports the same families of game Sandholm's notebook does, each with a static constructor:
| constructor | payoff field | used for |
|---|---|---|
Game.normalForm(A, B) | F[1] = A·y, F[2] = Bᵀ·x | bimatrix games (square) |
Game.linear(M) | F[1] = M₁₁·x + M₁₂·y, F[2] = M₂₁·x + M₂₂·y | general linear multi-population |
Game.symmetric(A) | F(x) = A·x (one population) | the S-series (triangle, tetrahedron) |
Game.trilinear(A) | three-body cyclic trilinear form | the cube (2×2×2) |
Game.nonlinear(fn, dims) | an arbitrary field F(state) | congestion games, custom payoffs |
For a symmetric two-strategy game pass B = transpose(A) to
normalForm. The bundled standardGames catalogue
supplies textbook presets — Hawk–Dove, Prisoner's Dilemma, Coordination, Stag
Hunt, Matching Pennies, Rock–Paper–Scissors (and its good/bad variants),
Zeeman's and Young's games, and the multi-population presets. We use these
throughout.
const g = Dynamo.standardGames.hawkDove(); // 2×2 bimatrix, dims [2,2]
g.dims; // → [2, 2]
g.payoff([[1, 0], [0, 1]]); // → [[2, 1], [-1, 0]]
// population 1 plays Hawk, population 2 plays Dove:
// Hawk earns 2 against a Dove, Dove earns 0 against a Hawk.
What a "mean dynamic" is
An evolutionary dynamic specifies how the population state moves:
a vector field V(x) giving the time derivative
ẋ. In Dynamo, every dynamic is the
mean dynamic of some agent-level revision protocol — the
deterministic ordinary differential equation obtained in the large-population
limit, in which the noise of individual revisions averages out and the state
follows the expected direction of motion. This is why a dynamic here
is a pure, deterministic function and not a stochastic simulation: it is the
mean field, not a sample path. (The single subtlety is
sampleBR, discussed below, which computes a multinomial
expectation in closed form precisely so that it, too, is the exact mean
dynamic rather than a simulation.)
Concretely, a dynamic in Dynamo.js is a function
V(state, game) → velocity returning ẋ in the same shape as the
state. Two invariants hold for every dynamic and are tested across
the suite:
-
Simplex-tangency. The velocity is tangent to the product
of simplices:
Σⱼ ẋₚⱼ = 0for every population p. Probability mass is conserved within each population — strategies that gain share take it from strategies that lose share — so the state never leaves the simplex. - Hard separation of logic and rendering. A dynamic owns no drawing; a renderer owns no dynamics. The dynamics, the integrator, the rest-point finder and the stability test all operate purely on numbers.
const g = Dynamo.standardGames.hawkDove();
const v = Dynamo.replicator([[0.7, 0.3], [0.7, 0.3]], g);
v; // → [[-0.084, 0.084], [-0.084, 0.084]]
v[0][0] + v[0][1]; // → 0 (mass conserved: Hawk's loss is Dove's gain)
2. The payoff machinery
Almost every dynamic is written not directly in terms of F but in
terms of four derived quantities. A Game exposes all four as
methods; understanding them is the key to reading the formulae that follow.
Take population p with strategy shares xₚ and payoffs
Fₚ.
Mean payoff F̄ₚ
The population's average payoff, the share-weighted mean of its strategies' payoffs:
F̄ₚ = Σⱼ xₚⱼ · Fₚⱼ
This is the payoff a randomly sampled member of population p earns. It is the baseline that "doing well" or "doing badly" is measured against.
const g = Dynamo.standardGames.hawkDove();
g.mean([[1, 0], [0, 1]]); // → [2, 0]
// population 1 (all Hawk) averages 2 against population 2 (all Dove);
// population 2 (all Dove) averages 0 against an all-Hawk opponent.
Excess payoff F̂ₚⱼ
Each strategy's payoff relative to its own population's mean — how much better (or worse) than average a strategy does:
F̂ₚⱼ = Fₚⱼ − F̄ₚ
Excess payoff is the engine of the imitative dynamics: a strategy
with positive excess is over-performing and tends to gain adherents; a
strategy with negative excess is under-performing and tends to lose them. By
construction the share-weighted excess is zero, Σⱼ xₚⱼ F̂ₚⱼ = 0.
const g = Dynamo.standardGames.hawkDove();
g.excess([[1, 0], [0, 1]]); // → [[0, -1], [-1, 0]]
// each population's played strategy sits exactly at its own mean (excess 0);
// the unplayed strategy would do worse (excess -1).
Positive part [F̂ₚⱼ]₊
The non-negative part of the excess payoff — keep over-performance, discard under-performance:
[F̂ₚⱼ]₊ = max(0, F̂ₚⱼ)
This is the building block of the excess-payoff dynamics (BNN, the general excess-payoff dynamic). Intuitively, agents move toward strategies that currently beat the population average, in proportion to how much they beat it; there is no reward for being merely average or below.
const g = Dynamo.standardGames.hawkDove();
g.excessPlus([[0.6, 0.4], [0.6, 0.4]]);
// the positive part of g.excess(...) at this interior state.
Projected payoff ΦFₚ
The payoff vector orthogonally projected onto the simplex tangent space — the component of Fₚ that respects the conservation of mass. With n strategies, the projection subtracts the payoff average from every coordinate:
ΦFₚ = (Iₙ − (1/n) Jₙ) · Fₚ (Jₙ = the all-ones matrix)
For a single two-strategy population the projection matrix is
[[½, −½], [−½, ½]]. The projected payoff is what the
projection dynamic is built from, and the projection matrix itself
is available as Dynamo.projectionMatrix(dims).
const g = Dynamo.standardGames.hawkDove();
g.projected([[1, 0], [0, 1]]); // → [[0.5, -0.5], [-0.5, 0.5]]
Dynamo.projectionMatrix([2]); // → [[0.5, -0.5], [-0.5, 0.5]]
3. The fourteen dynamics
Below is the full catalogue. For each dynamic we give its API name, its exact
formula in the notation above, the behavioural rule it encodes, its
rest-point character (whether its rest points are precisely
the game's Nash equilibria, marked Nash, or include extra
non-Nash rest points, marked Automatic), the
stability test its characterisation selects, what it tends
to converge to, and a short example. Every plain dynamic is a field
V(state, game); the parameterised ones are factories
that take a parameter and return such a field.
A note on the metadata. Each field carries
.characterisation = { rp, s, ao }. The rp value
records the rest-point character; the s value selects the
stability test (see §4): 'Smooth' asks for the eigenvalue test,
anything else asks for the kink-robust radial-contraction test. These come
straight from Sandholm's notebook.
Summary table
| API | formula | rest points | stability test |
|---|---|---|---|
replicator | ẋₚⱼ = xₚⱼ · F̂ₚⱼ | Automatic | eigenvalue |
maynardSmith | ẋₚ = (1/F̄ₚ) · replicatorₚ | Automatic | eigenvalue |
logit(η) | softmax(Fₚ/η)ⱼ − xₚⱼ | Automatic | eigenvalue |
iLogit(η) | xₚⱼ·e^{Fₚⱼ/η} / Σₖ xₚₖ·e^{Fₚₖ/η} − xₚⱼ | Automatic | radial |
br | logit(0.001) | Nash | radial |
exactBR | BR(x) − x (ties split evenly) | Nash | radial |
temperedLogit(η, Q) | Zusai tempered logit of Q(F) | Automatic | radial |
temperedBR(Q) | temperedLogit(0.001, Q) | Nash | radial |
bnn | [F̂ₚⱼ]₊ − xₚⱼ · Σₖ[F̂ₚₖ]₊ | Nash | radial |
smith | Σₖ xₚₖ[Fₚⱼ−Fₚₖ]₊ − xₚⱼ Σₖ[Fₚₖ−Fₚⱼ]₊ | Nash | radial |
excessPayoff | σ̃ₚⱼ − xₚⱼ·Σₖ σ̃ₚₖ, σ̃ = ([F̂]₊)² | Nash | radial |
projection | tangent-cone projection of F | Nash | radial |
sampleBR(k) | E[BR(k-sample)] − x (exact) | Automatic | eigenvalue |
selMut(M) | Mᵀ·diag(x)·F − (x·F)·x | Automatic | radial |
combine(A, B, wA, wB) | wA·A + wB·B | Nash | radial |
replicator — Dynamo.replicator
Formula. ẋₚⱼ = xₚⱼ · F̂ₚⱼ.
Intuition. The archetypal imitative dynamic. A strategy's growth rate equals its excess payoff: strategies doing better than their population's mean grow, those doing worse shrink, and the growth rate is proportional to the strategy's current share — a strategy with no adherents stays at zero. This is the deterministic limit of imitation-of-success and is equivalent to natural selection in the biological reading.
Rest points and stability. Automatic: every vertex
of the simplex is a rest point (because xₚⱼ = 0 kills that
coordinate), so the replicator rests at all pure profiles, not just
Nash ones — interior Nash equilibria are rest points too, but the boundary
contributes extra non-Nash rest points. The dynamic is smooth, so stability
is read from the Jacobian's eigenvalues.
Convergence. Game-dependent. In Hawk–Dove it carries the interior of the square to an anti-coordination corner; in Matching Pennies and Rock–Paper–Scissors it produces closed orbits around a centre (it conserves a relative-entropy quantity for these zero-sum games); in coordination games it converges to a pure equilibrium.
const g = Dynamo.standardGames.hawkDove();
Dynamo.replicator([[0.7, 0.3], [0.7, 0.3]], g);
// → [[-0.084, 0.084], [-0.084, 0.084]] (both populations drift toward Dove)
const traj = Dynamo.trajectory(g, Dynamo.replicator, [[0.6, 0.4], [0.4, 0.6]], { tMax: 40 });
traj.states.at(-1); // → [[1, 0], [0, 1]] (settles at an anti-coordination corner)
maynardSmith — Dynamo.maynardSmith
Formula. ẋₚ = (1/F̄ₚ) · replicatorₚ.
Intuition. The Maynard Smith replicator rescales the standard replicator by the inverse of the population's mean payoff. This makes growth rates depend on relative rather than absolute payoff differences, which is the form natural when payoffs are interpreted as fitnesses (numbers of offspring). It shares all the orbits of the standard replicator but traverses them at a different speed.
Caveat (load-bearing). Because it divides by F̄ₚ, the Maynard Smith dynamic requires strictly positive payoffs: if a population's mean payoff is zero or negative the rescaling is undefined or sign-flipping. Use a game whose payoff matrix is positive (add a constant to every entry if necessary — this leaves the standard replicator's orbits unchanged).
Rest points and stability. Automatic (the same rest points as the replicator); smooth, so the eigenvalue test applies.
// Shift Hawk-Dove so every payoff is positive, then run Maynard Smith.
const A = [[1, 4], [2, 3]]; // = Hawk-Dove + 2, all entries > 0
const g = Dynamo.Game.normalForm(A, Dynamo.transpose(A), { name: 'Hawk-Dove (shifted)' });
Dynamo.maynardSmith([[0.7, 0.3], [0.7, 0.3]], g);
// same direction as the replicator, rescaled by 1/F̄ₚ.
logit(η) — Dynamo.logit(eta)
Formula. ẋₚⱼ = softmax(Fₚ/η)ⱼ − xₚⱼ, where
softmax(v)ⱼ = e^{vⱼ} / Σₖ e^{vₖ}.
Intuition. The canonical perturbed best response.
Agents tend toward higher-payoff strategies but with bounded rationality
governed by the noise level η: the target distribution is the
Boltzmann (soft-max) distribution over current payoffs, and the state relaxes
toward it. As η → ∞ agents become indifferent and the target is
uniform; as η → 0 the soft-max concentrates on the best response.
This is the deterministic limit of logit choice.
Numerical note. The library uses a numerically
stable soft-max — it subtracts the maximum payoff before
exponentiating — so even very small η (as in br, which is
logit(0.001)) cannot overflow.
Rest points and stability. Automatic: the rest
points are the logit equilibria (fixed points of the soft-max best
response), which approach the Nash equilibria as η → 0 but are
not exactly Nash for positive η. Smooth, so the eigenvalue test applies.
const g = Dynamo.standardGames.coordination();
const dyn = Dynamo.logit(0.1);
Dynamo.restPoints(g, dyn).map((p) => [p.state, Dynamo.classify(g, dyn, p.state).type]);
// two near-pure sinks at ≈[[1,0],[1,0]] and ≈[[0,1],[0,1]], an interior saddle.
// (Compare the exact pure Nash equilibria the η→0 limit would give.)
iLogit(η) — Dynamo.iLogit(eta)
Formula.
ẋₚⱼ = xₚⱼ·e^{Fₚⱼ/η} / Σₖ xₚₖ·e^{Fₚₖ/η} − xₚⱼ.
Intuition. The imitative logit. It differs from the
plain logit by weighting each strategy's exponential by its current share
xₚⱼ. Behaviourally, an agent samples an opponent strategy in
proportion to its prevalence and switches with a logit probability — so, like
the replicator, an absent strategy can never be adopted, but unlike the
replicator the switching is soft-max rather than payoff-linear.
Rest points and stability. Automatic. Its stability
character is HiperESS (not Smooth), so the
kink-robust radial-contraction test is used rather than the
eigenvalue test.
const g = Dynamo.standardGames.hawkDove();
Dynamo.iLogit(0.1)([[0.7, 0.3], [0.7, 0.3]], g);
// → [[-0.659, 0.659], [-0.659, 0.659]] (strong soft pull toward Dove)
br — Dynamo.br
Formula. br = logit(0.001).
Intuition (important subtlety). Dynamo defines the
best-response dynamic not as a discontinuous correspondence but as a
very sharp logit — Logit[0.001]. The tiny noise level makes the
soft-max effectively select the best reply while keeping the field smooth
enough to integrate. This is the notebook's own convention and is what
Dynamo.br evaluates. Behaviourally: revising agents switch to a
(near-)best response to the current state.
Rest points and stability. Nash: the rest points are
the game's Nash equilibria. Its characterisation is ESS, so the
radial-contraction test is used (the field is effectively kinked at this noise
level, and convergence to a strict Nash can be too slow for a fixed-horizon
integration test to catch).
const g = Dynamo.standardGames.hawkDove();
Dynamo.br([[0.7, 0.3], [0.7, 0.3]], g);
// points sharply toward the current best reply (Dove against a mostly-Hawk field).
// Need the genuine, evenly-split best-response correspondence instead?
Dynamo.exactBR([[0.7, 0.3], [0.7, 0.3]], g); // → [[-0.7, 0.7], [-0.7, 0.7]]
exactBR — Dynamo.exactBR
Formula. ẋ = BR(x) − x, with the
best-response correspondence resolved by an even split across tied best
replies.
Intuition. The textbook best-response dynamic in its exact
form: the target is the best-response vertex (or the uniform mix over tied
best replies), and the state moves straight toward it. Offered alongside
Dynamo's smoothed br for when the genuine correspondence is
wanted rather than the sharp-logit surrogate. The helper
Dynamo.bestResponse(state, game) returns the target itself.
Rest points and stability. Nash / radial test, as
for br.
const g = Dynamo.standardGames.hawkDove();
Dynamo.bestResponse([[0.7, 0.3], [0.7, 0.3]], g); // → [[0, 1], [0, 1]] (Dove is best)
Dynamo.exactBR([[0.7, 0.3], [0.7, 0.3]], g); // → [[-0.7, 0.7], [-0.7, 0.7]]
temperedLogit(η, Q) — Dynamo.temperedLogit(eta, Q)
Formula (Zusai 2011). With payoff transformation
Q (default identity) and mₚ = maxₖ Q(F)ₚₖ:
ẋₚⱼ = softmax(Q(F)ₚ/η)ⱼ · Σₖ xₚₖ·(mₚ − Q(F)ₚₖ) − xₚⱼ·(mₚ − Q(F)ₚⱼ)
Intuition. A "tempered" version of the logit in which the
revision rate is modulated by how far below the current best a strategy sits:
the outflow term xₚⱼ·(mₚ − Q(F)ₚⱼ) drains a strategy faster the
worse it does, while inflow is distributed by the soft-max. The optional
transformation Q lets you apply a monotone reshaping of payoffs
before the rule is applied.
Rest points and stability. Automatic; its stability
character is Unknown, so the radial-contraction test is used.
const g = Dynamo.standardGames.hawkDove();
Dynamo.temperedLogit(0.1)([[0.7, 0.3], [0.7, 0.3]], g);
// → [[-0.275, 0.275], [-0.275, 0.275]]
// With a payoff reshaping Q (here squaring):
Dynamo.temperedLogit(0.1, (v) => v * v)([[0.7, 0.3], [0.7, 0.3]], g);
temperedBR(Q) — Dynamo.temperedBR(Q)
Formula. temperedBR(Q) = temperedLogit(0.001, Q).
Intuition. The sharp-noise limit of the tempered logit — the
tempered analogue of br. As with br the tiny noise
level makes it select (near-)best responses while keeping the field integrable.
Rest points and stability. Nash; stability character
Unknown, so the radial-contraction test is used.
const g = Dynamo.standardGames.hawkDove();
Dynamo.temperedBR()([[0.7, 0.3], [0.7, 0.3]], g); // tempered, sharp best response
bnn — Dynamo.bnn
Formula. ẋₚⱼ = [F̂ₚⱼ]₊ − xₚⱼ · Σₖ[F̂ₚₖ]₊.
Intuition. The Brown–von Neumann–Nash
dynamic, the prototypical excess-payoff dynamic. Inflow to a strategy
is its positive excess payoff [F̂ₚⱼ]₊; the second term removes
mass from every strategy in proportion to its share so the whole stays on the
simplex. Agents are drawn toward strategies that currently beat the population
average, with no requirement of imitation — so, unlike the replicator, BNN can
revive a strategy that has died out if it would now do well.
Rest points and stability. Nash: BNN rests exactly
at the Nash equilibria. Stability character ESS, so the
radial-contraction test is used — appropriate because BNN's convergence to a
strict Nash is only polynomial (its radial velocity behaves like −y² there),
which a fixed-horizon integration test would miss.
const g = Dynamo.standardGames.hawkDove();
Dynamo.bnn([[1, 0], [0, 1]], g); // → [[0, 0], [0, 0]] (this corner is a Nash rest point)
Dynamo.bnn([[0.6, 0.4], [0.6, 0.4]], g); // non-zero: pushes toward the Nash set
smith — Dynamo.smith
Formula.
ẋₚᵢ = Σₖ xₚₖ·[Fₚᵢ − Fₚₖ]₊ − xₚᵢ · Σₖ[Fₚₖ − Fₚᵢ]₊.
Intuition. The Smith pairwise-comparison
dynamic. Mass flows directly from strategy k to strategy j at
a rate equal to the payoff gain [Fₚⱼ − Fₚₖ]₊: a revising agent
compares its current strategy with a randomly chosen alternative and switches
only if the alternative pays strictly more, by an amount proportional to the
gain. Inflow to i is the total such flow into it; outflow is the total
flow out of it.
Rest points and stability. Nash: Smith rests exactly
at the Nash equilibria. Stability character ESS → radial test.
const g = Dynamo.standardGames.rockPaperScissors();
Dynamo.smith([[0.5, 0.3, 0.2]], g); // → [[-0.18, 0.3, -0.12]]
// Paper (the current best reply) gains; Rock and Scissors shed mass.
excessPayoff — Dynamo.excessPayoff
Formula. With revision potential
σ̃ₚⱼ = ([F̂ₚⱼ]₊)²:
ẋₚⱼ = σ̃ₚⱼ − xₚⱼ · Σₖ σ̃ₚₖ.
Intuition. A general excess-payoff dynamic of the same family as BNN, but with the inflow given by the square of the positive excess rather than the excess itself. Squaring sharpens the response: strongly over-performing strategies attract disproportionately more mass. BNN is the linear member of this family; this is the quadratic one.
Rest points and stability. Nash → radial test
(ESS).
const g = Dynamo.standardGames.hawkDove();
Dynamo.excessPayoff([[1, 0], [0, 1]], g); // → [[0, 0], [0, 0]] (Nash rest point)
Dynamo.excessPayoff([[0.6, 0.4], [0.6, 0.4]], g); // non-zero away from Nash
projection — Dynamo.projection
Formula. The closest-point projection of the payoff vector Fₚ onto the simplex tangent cone at xₚ, computed by the active-set method.
Intuition. The projection dynamic moves the
state in the feasible direction nearest to the raw payoff vector. In the
interior, where every strategy can both gain and lose mass, this is just the
orthogonal tangent projection (Iₙ − (1/n)Jₙ)·Fₚ — the projected
payoff ΦFₚ of §2. On a face (some xₚⱼ = 0) the active-set
procedure drops any boundary strategy whose projected velocity would push the
share negative and re-projects, so the flow stays inside the simplex. It is
the most "geometric" of the dynamics: follow the payoff gradient, but only as
far as feasibility allows.
Rest points and stability. Nash: the projection
dynamic rests exactly at the Nash equilibria. Stability character
ESS, and the field is kinked at the faces, so the
radial-contraction test is used.
const g = Dynamo.standardGames.hawkDove();
Dynamo.projection([[0.7, 0.3], [0.7, 0.3]], g); // → [[-0.2, 0.2], [-0.2, 0.2]] (interior: = ΦF)
Dynamo.projection([[1, 0], [0, 1]], g); // → [[0, 0], [0, 0]] (Nash rest point)
sampleBR(k) — Dynamo.sampleBR(k)
Formula. ẋ = E[BR(empirical)] − x, the
exact expectation of the best response to an empirical state
formed from k independent draws per population.
Intuition (important subtlety). The sampling
best-response dynamic models an agent who, instead of best-responding
to the true population state, best-responds to a small sample of
k opponents. With finite k the agent's empirical estimate is
noisy, which smooths the best-response correspondence into a genuine vector
field — and recovers exact best response only as k → ∞.
Crucially, sampleBR is not a simulation: each
population's empirical share is multinomial (Binomial in the two-strategy
case), and the dynamic computes the multinomial expectation in closed
form, so it is fully deterministic — the exact mean dynamic, not a sample
path.
Scope. The closed-form expectation is implemented for two-strategy populations (the 2×2 case); calling it on a population with more than two strategies throws.
Rest points and stability. Automatic; smooth, so the eigenvalue test applies. As k grows it approaches the (Nash-resting) best-response dynamic.
const g = Dynamo.standardGames.hawkDove();
Dynamo.sampleBR(5)([[0.7, 0.3], [0.7, 0.3]], g);
// → [[-0.537, 0.537], [-0.537, 0.537]] (deterministic; smaller pull than exactBR)
Dynamo.sampleBR(50)([[0.7, 0.3], [0.7, 0.3]], g); // closer to exactBR as k grows
selMut(M) — Dynamo.selMut(M)
Formula. With per-population row-stochastic mutation
matrices M (an array, one n×n matrix per population):
ẋₚ = Mₚᵀ · diag(xₚ) · Fₚ − (xₚ·Fₚ) · xₚ.
Intuition. The selection–mutation dynamic
augments replicator-style selection with mutation: after the
selection step diag(xₚ)·Fₚ (payoff-weighted shares), the mutation
matrix Mₚ redistributes a fraction of each strategy's mass to others,
modelling errors, experimentation or migration. With M the identity
there is no mutation and the dynamic is exactly the replicator
— a useful sanity check.
Rest points and stability. Automatic; stability
character Unknown, so the radial-contraction test is used.
const g = Dynamo.standardGames.hawkDove();
const I = [[1, 0], [0, 1]];
Dynamo.selMut([I, I])([[0.7, 0.3], [0.7, 0.3]], g);
// → [[-0.084, 0.084], [-0.084, 0.084]] (identity M ⇒ the replicator exactly)
const M = [[0.98, 0.02], [0.02, 0.98]]; // 2% mutation each way
Dynamo.selMut([M, M])([[0.7, 0.3], [0.7, 0.3]], g); // selection plus mutation
combine(A, B, wA, wB) — Dynamo.combine(A, B, wA, wB)
Formula. ẋ = wA·A(x) + wB·B(x) (weights default
to ½, ½).
Intuition. A convex (or, with arbitrary weights, linear)
combination of two dynamics, modelling a population in which
different agents follow different revision protocols. Because every dynamic is
simplex-tangent, any weighted sum of them is too, so the combination is a
legitimate dynamic. Note that the two argument dynamics must already be
fields: for a parameterised dynamic, call its factory first (e.g.
pass Dynamo.logit(0.1), not Dynamo.logit).
Rest points and stability. Tagged Nash with
stability character Unknown, so the radial-contraction test is
used. (The actual rest set is, of course, where the weighted sum vanishes,
which depends on the components and weights.)
const g = Dynamo.standardGames.hawkDove();
const mixed = Dynamo.combine(Dynamo.replicator, Dynamo.logit(0.1), 0.5, 0.5);
mixed([[0.7, 0.3], [0.7, 0.3]], g); // → [[-0.383, 0.383], [-0.383, 0.383]]
// half imitation (replicator) + half perturbed best response (logit).
The dynamics registry
Every dynamic is also reachable through Dynamo.dynamics, a
registry keyed by name. Plain fields appear directly; parameterised dynamics
appear as their factories. This is convenient for building UI that lets a user
pick a dynamic by name.
Object.keys(Dynamo.dynamics);
// → replicator, maynardSmith, bnn, smith, excessPayoff, projection, br,
// exactBR, logit, iLogit, temperedLogit, temperedBR, sampleBR, selMut, combine
const dyn = Dynamo.dynamics.logit(0.1); // factories still take their parameter
4. Rest points, stability, and Nash / ESS
Beyond simulating trajectories, Dynamo locates the rest points of a dynamic and classifies their stability — the skeleton of every phase diagram. It also computes a game's Nash equilibria independently, both as a cross-check and to make precise the relationship between equilibria and rest points.
Finding rest points — restPoints(game, dynamic)
A rest point is a zero of the vector field: a state at which
the dynamic prescribes no motion, ẋ = 0. Because every dynamic is
simplex-tangent, the search is carried out in the reduced coordinates
(dimension Σₚ(nₚ − 1), at most 3 for any Dynamo shape), where
Vᵣ(r) = 0 is a well-posed root-finding problem.
restPoints seeds a damped N-dimensional Newton iteration
from a grid of points spanning the whole product of simplices — interior
and boundary, so vertices and edges are covered — keeps each iterate
on the simplex, discards any seed that fails to converge or lands off the
simplex, and de-duplicates the survivors. The Jacobian is obtained by finite
differences, so the method needs no analytic derivatives and works for every
dynamic and every dims. Each result carries both its reduced
coordinates and the full state:
const g = Dynamo.standardGames.hawkDove();
Dynamo.restPoints(g, Dynamo.replicator).map((p) => p.state);
// → the four pure corners plus the interior point [[0.5,0.5],[0.5,0.5]]
// (each is a zero of the replicator field for Hawk-Dove).
Classifying stability — classify(game, dynamic, restState)
Given a rest point, classify returns its type —
'sink', 'source', 'saddle',
'centre', or the complex variants 'spiral sink' /
'spiral source'. Which of two tests it uses is decided by the
dynamic's characterisation.s:
-
Smooth dynamics (replicator, Maynard Smith, logit,
sampleBR — those tagged
'Smooth'): the eigenvalue test. The library forms the Jacobian of the reduced field by central differences (Dynamo.jacobian) and reads its eigenvalues (Dynamo.classifyEigen). The signs of the real parts classify the point — all negative ⇒ sink, all positive ⇒ source, mixed signs ⇒ saddle, a vanishing real part with no positive part ⇒ centre; a non-zero imaginary part marks the spiral cases. The returned object includes theeigenvaluesand thejacobian. -
Non-smooth dynamics (BNN, Smith, BR, projection,
excessPayoff, the tempered family, iLogit, selMut, combine — anything not
'Smooth'): the kink-robust radial-contraction test (Dynamo.numericStability). It samples the radial component of the field,u · V(x₀ + ε·u), over a spread of feasible unit directions u (boundary rest points sample only their inward directions). A direction is contracting when the flow there points back toward the rest point and expanding when it points away. All contracting ⇒ sink, all expanding ⇒ source, both present ⇒ saddle. This is the right tool for two reasons: the kinked fields have no well-defined Jacobian at the kinks, and the Nash-stationary dynamics can converge only polynomially near a strict Nash (e.g. BNN's radial velocity ≈ −y² there), which a fixed-horizon integration test would miss.
The returned object records which test ran in its method field
('eigenvalue' or 'numeric'). Use
classifyAll(game, dynamic, points) to classify a whole list of
rest points at once.
const g = Dynamo.standardGames.hawkDove();
const pts = Dynamo.restPoints(g, Dynamo.replicator);
Dynamo.classifyAll(g, Dynamo.replicator, pts)
.map((p) => [p.state, p.stability.type]);
// → [[0,1],[0,1]] sink (anti-coordination corner)
// [[1,0],[1,0]] source (coordination corner)
// [[0,1],[1,0]] sink … (the two anti-coordination corners attract)
// [[1,0],[0,1]] sink
// [[0.5,0.5],[0.5,0.5]] saddle (the interior mixed equilibrium)
These classifications match the known theory the suite is validated against: the two-population Hawk–Dove has an interior saddle; Matching Pennies and Rock–Paper–Scissors have an interior centre; coordination games have pure-strategy sinks.
const mp = Dynamo.standardGames.matchingPennies();
Dynamo.classify(mp, Dynamo.replicator, [[0.5, 0.5], [0.5, 0.5]]).type; // → 'centre'
Nash equilibria and how they relate to rest points
A profile is a Nash equilibrium when, in every population,
only best responses receive positive share — no agent could do better by
switching. Dynamo.nashEquilibria(game) enumerates them in closed
form: it tests the pure profiles with the best-response criterion and adds the
interior totally-mixed equilibrium when one exists (solving the bilinear
indifference conditions F[p]₁ = F[p]₂). Each result is flagged
strict (a strict pure equilibrium) and mixed
(interior). Two predicates, Dynamo.isNash(game, r) and
Dynamo.isStrictNash(game, r), test a single reduced profile.
const g = Dynamo.standardGames.hawkDove();
Dynamo.nashEquilibria(g).map((e) => ({ reduced: e.reduced, strict: e.strict, mixed: e.mixed }));
// → two strict pure equilibria (the anti-coordination corners) and the
// interior mixed equilibrium [0.5, 0.5] (mixed: true, strict: false).
The connection is what makes the rest-point character
(§3) meaningful. A central theorem of evolutionary game theory is that the
Nash-stationary dynamics — those tagged Nash here: BNN,
Smith, BR, exactBR, projection, excessPayoff, temperedBR — rest
exactly at the Nash equilibria of the game, no more and no
less. For them, restPoints and nashEquilibria return
the same set (up to numerical tolerance), and the closed-form Nash enumeration
is a genuine independent cross-check on the Newton solver.
The Automatic dynamics — replicator, Maynard Smith, logit, iLogit,
sampleBR, selMut — rest at more points than the Nash set. Every
vertex of the simplex is a rest point of the replicator (and its kin) whether
or not it is Nash, because a strategy at zero share cannot grow; the logit
family rests at logit equilibria that only approach Nash as the noise
η → 0. So for these the rest points are a superset of
the equilibria, and stability is what sorts the meaningful ones (the
Nash sinks) from the spurious ones (unstable boundary rest points).
Finally, stability ties back to the classical refinement of evolutionary stability. An ESS is, under each of the principal dynamics, an asymptotically stable rest point — a sink. So the standard route to checking evolutionary stability with this library is: find the rest points, classify them, and read off which Nash equilibria are sinks. In Hawk–Dove, for instance, the interior mixed equilibrium is the single-population ESS — and indeed appears as the attractor of the single-population replicator on the triangle — while in the two-population square the same mixed profile is a saddle, exactly as the theory predicts.
const g = Dynamo.standardGames.hawkDove();
const dyn = Dynamo.bnn; // a Nash-stationary dynamic
const rest = Dynamo.restPoints(g, dyn);
const nash = Dynamo.nashEquilibria(g);
rest.length === nash.length; // → true: BNN rests exactly at the Nash set
Dynamo.classifyAll(g, dyn, rest)
.filter((p) => p.stability.type === 'sink') // the evolutionarily stable equilibria
.map((p) => p.state);
API reference
API reference
Dynamo.js is a zero-dependency JavaScript port of William H. Sandholm's
Dynamo Mathematica suite. It is organised as a small set of ES
modules under src/, each owning one concern: the seeded
generator, the linear-algebra kit, the state model, games and payoffs, the
evolutionary dynamics, integration, geometry, rest points, stability and
the two SVG renderers. The engine owns no rendering; the renderers own no
simulation logic. A dynamic is a pure function
(state, game) → velocity.
Conventions used throughout this reference
-
A full state is one mixed strategy per population, a
number[][]whose rows each sum to one — e.g.[[x1, x2], [y1, y2]]. A reduced state drops the last coordinate of each population (it is1 − Σ), giving theΣ(np−1)-dimensional point that is integrated, root-found and drawn. A velocity has the shape of a full state and is simplex-tangent (Σj &xdot;p,j = 0for every population). -
dimsis the list of strategy counts, one per population, and fixes the geometry. The five supported shapes are[2,2](square),[3](triangle),[4](tetrahedron),[3,2](prism) and[2,2,2](cube). - British English throughout (colour, behaviour, normalise, centre).
Importing
There are two ways to load the library, and they expose different surfaces.
1. The bundle — dist/dynamo.js (a Dynamo global)
dist/dynamo.js is a single hand-rolled bundle with no
dependencies. Dropped in via a classic <script> it
assigns a global Dynamo object (and a CommonJS
module.exports when one is present). The global is the union
of every public export of all twelve modules — so
everything in this reference, including the renderers, the geometry, the
colour maps and the linear-algebra helpers, is reachable as
Dynamo.<name>. The page opens with no server.
<script src="dist/dynamo.js"></script>
<script>
const game = Dynamo.standardGames.hawkDove();
const svg = Dynamo.phasePortraitSVG(game, Dynamo.replicator);
document.body.insertAdjacentHTML('beforeend', svg);
</script>
2. ES modules
Every file under src/ is a standalone ES module. The package
barrel src/index.js (the package's main)
re-exports the engine surface — the generator, the state helpers,
games, dynamics, integration, rest points and stability. It deliberately
does not re-export the renderers
(render-2d.js, render-3d.js), the geometry
(geometry.js), the colour maps (colormap.js),
the linear-algebra kit (linalg.js) or
gameCatalog. Those are reached by importing their module
directly (a deep import), or via the bundle global above.
// the engine surface, from the barrel
import { Game, replicator, trajectory, restPoints, classify } from './src/index.js';
// renderers / geometry / colour maps / linear algebra — deep imports
import { phasePortraitSVG, viewTransform } from './src/render-2d.js';
import { phasePortrait3dSVG, camera3d } from './src/render-3d.js';
import { geometryFor } from './src/geometry.js';
import { heatColor, scaleStops, lerpHex } from './src/colormap.js';
import { gameCatalog } from './src/game.js';
ES modules require a local HTTP server in development; opening pages over
file:// fails on import. The bundle has no such
constraint.
The table below summarises where each module's exports live. “Barrel”
means re-exported by src/index.js; “Deep / bundle”
means reachable only by importing the module directly or via the
Dynamo global.
| Module | In the index.js barrel? | On the Dynamo global? |
|---|---|---|
prng.js | Yes | Yes |
linalg.js | No (deep / bundle) | Yes |
state.js | Yes | Yes |
game.js | Partly: Game, standardGames, projectionMatrix (not gameCatalog) | Yes (incl. gameCatalog) |
dynamics.js | Yes | Yes |
integrator.js | Yes | Yes |
geometry.js | No (deep / bundle) | Yes |
rest-points.js | Yes | Yes |
stability.js | Yes | Yes |
colormap.js | No (deep / bundle) | Yes |
render-2d.js | No (deep / bundle) | Yes |
render-3d.js | No (deep / bundle) | Yes |
game.js — population games and payoffs
A Game is, at heart, a payoff field F(x): given a
full state it returns the payoff to every strategy of every population, as a
number[][] of the same shape as the state. On top of the field
sit four derived quantities — mean, excess, positive excess and the
tangent-space projection — that every excess-payoff and comparison dynamic
is written in terms of. Prefer the static constructors below to the raw
constructor.
Static constructors
| Signature | Parameters | Field F(x) | Resulting dims |
|---|---|---|---|
Game.normalForm(A, B, opts) |
A, B: payoff matrices (number[][]) for populations 1 and 2. opts: { name, labels }. |
F[1] = A·y, F[2] = Bᵀ·x. For a symmetric game pass B = transpose(A). |
[A.length, A[0].length] — e.g. [2,2]. Sets g.A, g.B. |
Game.symmetric(A, opts) |
A: one n×n payoff matrix. opts: { name, labels }. |
F(x) = A·x; state is one population [[x1…xn]]. The single-population “S-series” case (triangle, tetrahedron). |
[A.length] — e.g. [3] or [4]. Sets g.A. |
Game.linear(M, opts) |
M: a 2×2 block matrix, each block a payoff matrix. opts: { name, labels }. |
F[1] = M₁₁·x + M₁₂·y, F[2] = M₂₁·x + M₂₂·y. |
[M[0][0].length, M[1][0].length]. Sets g.M. |
Game.trilinear(A, opts) |
A: A[p][j] is a payoff matrix for population p's strategy j. opts: { name, labels }. |
Three-body interaction, paired as in the 2×2×2 notebook: F¹ⱼ = z·(A[1][j]·y), F²ⱼ = z·(A[2][j]·x), F³ⱼ = x·(A[3][j]·y) — notebook matrices drop in verbatim. Exactly three populations. The genuine 2×2×2 cube payoff. |
A.map(Ap => Ap.length) — e.g. [2,2,2]. Sets g.A. |
Game.nonlinear(fn, dims, opts) |
fn: an arbitrary field F(state) → number[][]. dims (default [2,2]): strategy counts. opts is spread onto the config (e.g. { name, labels, kind }). |
F(x) = fn(x), given directly (e.g. congestion games). |
the dims argument. |
The raw constructor is
new Game({ payoff, dims = [2,2], kind = 'custom', name, labels }).
payoff is required and must be a function; otherwise a
TypeError is thrown. labels defaults to
s<p>.<j> per strategy. The instance exposes
dims, pop (= dims.length),
kind, name and labels.
Instance methods
| Method | Returns | Meaning |
|---|---|---|
payoff(state) | number[][] | The payoff field F(x): payoff to each strategy of each population. |
mean(state) | number[] | Average payoff per population, F̄p = Σj xp,j·Fp,j — one scalar per population. |
excess(state) | number[][] | Excess payoff F̂p,j = Fp,j − F̄p. |
excessPlus(state) | number[][] | Positive part of the excess payoff, [F̂p,j]+ = max(0, F̂p,j). |
projected(state) | number[][] | Payoff projected onto the simplex tangent space, ΦFp = (In − (1/n)Jn)·Fp, per population. |
Module functions and data
| Export | Signature | Returns / meaning |
|---|---|---|
projectionMatrix |
projectionMatrix(dims) |
The block-diagonal orthogonal projection onto the product of simplex tangent spaces; the p-th block is In − (1/n)Jn. For a single two-strategy population this is [[½, −½], [−½, ½]]. Returns a (Σdims)×(Σdims) matrix. |
standardGames |
object of zero-argument factories | The preset games; each call builds a fresh Game. Listed below. |
gameCatalog (deep / bundle only) |
Array of shape descriptors |
The presets grouped by the shape they live on; drives the showcase. Each entry: { id, dims, shape, blurb, games }, where games is a list of standardGames keys valid for that shape. |
standardGames presets
| Key | dims / shape | Game and behaviour |
|---|---|---|
prisonersDilemma | [2,2] square | Prisoner's Dilemma — strict dominance; the single sink is mutual defection. Labels Cooperate/Defect. |
coordination | [2,2] square | Coordination — two pure sinks, an interior saddle. Labels A/B. |
stagHunt | [2,2] square | Stag Hunt — payoff- vs risk-dominant coordination. Labels Stag/Hare. |
hawkDove | [2,2] square | Hawk–Dove (Chicken) — anti-coordination, an interior ESS. Labels Hawk/Dove. |
matchingPennies | [2,2] square | Matching Pennies — zero-sum, a centre: replicator orbits are closed. Labels Heads/Tails. |
rockPaperScissors | [3] triangle | Rock–Paper–Scissors — a conservative cycle around the centre. |
goodRPS | [3] triangle | Good RPS — net positive cycle; the centre is an attractor. |
badRPS | [3] triangle | Bad RPS — net negative cycle; the centre repels, orbits spiral out. |
coordination3 | [3] triangle | Three-strategy coordination — three pure sinks. |
zeeman | [3] triangle | Zeeman's game — an interior fixed point and a limit cycle. |
young | [3] triangle | Young's game — an interior equilibrium reached from much of the simplex. |
cyclic4 | [4] tetrahedron | Four-strategy cyclic game (each strategy beats the next). |
coordination4 | [4] tetrahedron | Four-strategy coordination — four pure sinks at the tetrahedron's vertices. |
outsideOption | [3,2] prism | Coordination with an outside option (the notebook's 3×2 preset). Labels A/B/Out vs a/b. |
threeWayCoordination | [2,2,2] cube | Three-way coordination: each population rewarded for matching the others. Trilinear. |
threeWayAnticoordination | [2,2,2] cube | Three-way anti-coordination: each population rewarded for differing from the others. Trilinear. |
The gameCatalog groups these into five shapes:
square ([2,2]),
triangle ([3]),
tetrahedron ([4]),
prism ([3,2]) and
cube ([2,2,2]).
import { Game, standardGames } from './src/index.js';
// A bespoke Hawk–Dove, by normal form.
const A = [[-1, 2], [0, 1]];
const hd = Game.normalForm(A, [[-1, 0], [2, 1]], { name: 'Hawk–Dove' });
hd.payoff([[0.5, 0.5], [0.5, 0.5]]); // → [[0.5, 0.5], [0.5, 0.5]]
hd.excess([[0.5, 0.5], [0.5, 0.5]]); // → [[0, 0], [0, 0]]
// A preset Rock–Paper–Scissors (triangle).
const rps = standardGames.rockPaperScissors();
rps.dims; // → [3]
dynamics.js — the evolutionary dynamics
Every dynamic is a vector field V(state, game) → velocity,
returning &xdot; in the shape of the state. Plain dynamics are
that field directly; parameterised dynamics are factories that take
a parameter and return such a field. Every field carries a
.dynamicName string and a
.characterisation = { rp, s, ao } metadata object transcribed
from the notebook. The s value selects the stability test:
'Smooth' → eigenvalues of the Jacobian; otherwise →
the radial-contraction (kink-robust) test (see stability.js).
Plain dynamics (fields)
| Export | Formula | characterisation (rp / s / ao) |
|---|---|---|
replicator | &xdot;p,j = xp,j·F̂p,j | Automatic / Smooth / false |
maynardSmith | &xdot;p = (1/F̄p)·replicatorp — needs positive payoffs: where a population's mean payoff reaches 0 (e.g. anywhere on Matching Pennies' indifference lines) the field is undefined, the trajectory turns NaN and is dropped from plots | Automatic / Smooth / false |
bnn | Brown–von Neumann–Nash: &xdot;p,j = [F̂p,j]+ − xp,j·Σk[F̂p,k]+ | Nash / ESS / false |
smith | Pairwise comparison: &xdot;p,i = Σk xp,k[Fp,i−Fp,k]+ − xp,iΣk[Fp,k−Fp,i]+ | Nash / ESS / false |
excessPayoff | &xdot;p,j = σ̃p,j − xp,j·Σkσ̃p,k, with revision potential σ̃ = ([F̂]+)² | Nash / ESS / false |
projection | Closest-point projection of the payoff onto the simplex tangent cone (active-set); for an interior state, (I−(1/n)J)·Fp | Nash / ESS / false |
br | Best response, as Dynamo defines it: the sharp logit logit(0.001) | Nash / ESS / false |
exactBR | &xdot; = BR(x) − x, ties split evenly across best replies (uses bestResponse). Discontinuous at ties — integrate with rk4; under rk45 the step collapses there and the trajectory truncates (check completed) | Nash / ESS / false |
Factories (return a field)
| Export | Signature | Field / meaning | characterisation (rp / s / ao) |
|---|---|---|---|
logit | logit(eta) | &xdot;p,j = softmax(Fp/η)j − xp,j (stable soft-max) | Automatic / Smooth / false |
iLogit | iLogit(eta) | Imitative logit: &xdot;p,j = xp,je^{Fp,j/η} / Σk xp,ke^{Fp,k/η} − xp,j | Automatic / HiperESS / false |
temperedLogit | temperedLogit(eta, Q = v => v) | Zusai (2011) tempered logit of the payoff transform Q(F); with mp = maxk Q(F)p,k, &xdot;p,j = softmax(Q(F)p/η)j·Σk xp,k(mp−Q(F)p,k) − xp,j(mp−Q(F)p,j) | Automatic / Unknown / false |
temperedBR | temperedBR(Q = v => v) | Tempered best response = temperedLogit(0.001, Q) | Nash / Unknown / false |
sampleBR | sampleBR(k) | Sample best response: &xdot; = E[BR(empirical)] − x, the exact expectation over k independent draws per population (a deterministic mean dynamic, not a simulation). Implemented for two-strategy populations only (throws otherwise). | Automatic / Smooth / false |
selMut | selMut(M) | Selection–mutation with per-population row-stochastic mutation matrices M (an array, one n×n matrix per population): &xdot;p = Mpᵀ·diag(xp)·Fp − (xp·Fp)xp. With identity M this is exactly the replicator. | Automatic / Unknown / false |
combine | combine(dynA, dynB, wA = 0.5, wB = 0.5) | Weighted combination of two fields: wA·dynA + wB·dynB. | Nash / Unknown / false |
Helpers and the registry
| Export | Signature | Returns / meaning |
|---|---|---|
bestResponse |
bestResponse(state, game) |
The best-response correspondence at state: a full-state-shaped array placing a vertex per population, with ties split evenly across the maximising strategies. |
dynamics |
object | Registry of all the dynamics, keyed by name: replicator, maynardSmith, bnn, smith, excessPayoff, projection, br, exactBR, logit, iLogit, temperedLogit, temperedBR, sampleBR, selMut, combine. The last seven are factories. |
import { replicator, logit, combine, standardGames, uniformState } from './src/index.js';
const rps = standardGames.rockPaperScissors();
const v = replicator(uniformState(rps.dims), rps); // uniformState(rps.dims) → [[1/3, 1/3, 1/3]]
// → a simplex-tangent velocity (each population's components sum to 0)
const smoothBR = logit(0.1); // a field
const mix = combine(replicator, smoothBR, 0.7, 0.3);
replicator.dynamicName; // → 'Replicator'
replicator.characterisation; // → { rp: 'Automatic', s: 'Smooth', ao: false }
integrator.js — trajectories on the simplex
Two integrators solve &xdot; = V(x): a fixed-step classical
Runge–Kutta (RK4, the robust default) and an adaptive
Dormand–Prince RK45 with error control for the sharp dynamics (e.g.
logit(0.001)). The dynamics are simplex-tangent, so the
constraint is preserved analytically; the state is nonetheless re-projected
after each step to remove numerical drift. Integration runs on the
flattened state, but V is evaluated on the structured state so
the maths is identical to the rest of the library.
trajectory(game, dynamic, x0, opts)
| Option | Type | Default | Meaning |
|---|---|---|---|
tMax | number | 20 | Horizon in dynamical time (integration stops when |t| ≥ tMax). |
dt | number | 0.05 | Step size for RK4; the initial step for RK45. |
method | 'rk4' | 'rk45' | 'rk4' | Fixed-step RK4, or adaptive Dormand–Prince RK45 with error control. |
direction | number | 1 | +1 integrates forward in time, −1 backward. |
tol | number | 1e-6 | Per-step error tolerance (RK45 only; ignored by RK4). |
reproject | boolean | true | Re-project (normalise) onto the product of simplices after each step. |
maxSteps | number | 20000 | Safety cap on the number of steps. |
Returns { times, states, points, completed }:
times:number[]— the sampled times, starting at0.states:number[][][]— the full state at each time.points:number[][]— the reduced plotting coordinates, thexp,1(first component) of each population at each time.completed:boolean—falsewhen the run hitmaxStepsbefore reachingtMax. The main way to get there: RK45's error control collapses the step at a discontinuous field (exactBRat a best-reply tie) and the trajectory silently truncates — preferrk4forexactBR, whose fixed steps average through the chatter.
flow(game, dynamic, x0, T, opts)
Flows a single state forward by total time T and returns the
final full state (a number[][]). Uses fixed-step RK4 with
opts.dt (default 0.05), normalising the state each
step. The workhorse for the numeric stability test and basins of attraction.
import { trajectory, flow, replicator, standardGames } from './src/index.js';
const hd = standardGames.hawkDove();
const tr = trajectory(hd, replicator, [[0.8, 0.2], [0.3, 0.7]], { tMax: 30, dt: 0.02 });
tr.points[tr.points.length - 1]; // → the trajectory's endpoint (reduced coords)
const sharp = trajectory(hd, replicator, [[0.9, 0.1], [0.1, 0.9]], { method: 'rk45', tol: 1e-8 });
const end = flow(hd, replicator, [[0.6, 0.4], [0.6, 0.4]], 50); // final full state
rest-points.js — rest points, Nash equilibria, ESS
A rest point is a zero of the vector field. Working in reduced coordinates, rest points are found by damped Newton from a grid of seeds spanning the whole product of simplices, then de-duplicated. For the Nash-stationary dynamics (BNN, Smith, BR, projection) these coincide with the game's Nash equilibria, which are also enumerated in closed form as an independent cross-check.
| Export | Signature | Returns / meaning |
|---|---|---|
restPoints |
restPoints(game, dynamic, opts) |
Zeros of the reduced field. Options: grid (Newton-seed
resolution; defaults to 8 when the reduced dimension is
≤ 2, 6 when it is 3, else 5),
tol (residual tolerance, default 1e-7),
dedup (merge radius, default 1e-4). Returns
{ reduced, state }[] — the reduced coordinate and the full
state of each distinct rest point. Works for any dims.
|
nashEquilibria |
nashEquilibria(game) |
The pure profiles that pass the best-response test, plus the interior
totally-mixed equilibrium when it exists (solved from the bilinear
indifference conditions). Returns
{ reduced, state, strict, mixed }[]. Implemented
for the two-population 2×2 case only (dims
[2,2]) — any other shape throws with a pointer to the
alternative: a game's Nash set is exactly the rest-point set of a
Nash-stationary dynamic, so use
restPoints(game, bnn) there.
|
nashStatus |
nashStatus(game, r, tol = 1e-7) — tol is relative to each population's payoff scale |
Nash status of a reduced profile r. Returns
{ nash, strict }: nash is true when every
strategy in a population's support is a best response;
strict additionally requires the profile to be pure and
each played strategy to uniquely maximise its population's payoff.
|
isNash |
isNash(game, r) |
Boolean shorthand for nashStatus(game, r).nash. |
isStrictNash |
isStrictNash(game, r) |
Boolean shorthand for nashStatus(game, r).strict. |
import { restPoints, nashEquilibria, isNash, bnn, standardGames } from './src/index.js';
const hd = standardGames.hawkDove();
restPoints(hd, bnn); // → [{ reduced:[…], state:[[…],[…]] }, …]
nashEquilibria(hd); // → […, { reduced:[…], state, strict, mixed:true }]
isNash(hd, [0.5, 0.5]); // → true / false
stability.js — classifying rest points
Two tests, chosen by the dynamic's characterisation.s:
smooth dynamics are classified by the eigenvalues of the reduced Jacobian;
non-smooth (kinked) dynamics by a local radial-contraction perturbation
test, which is robust to the kinks of BNN/Smith/BR/projection and to their
polynomial convergence near a strict Nash.
The type strings used throughout are:
'sink', 'source', 'saddle',
'centre', 'spiral sink',
'spiral source', 'degenerate' and
'unknown'.
| Export | Signature | Returns / meaning |
|---|---|---|
classify |
classify(game, dynamic, restState) |
Classify one rest point (given by its full state), choosing the
test by dynamic.characterisation.s. For
'Smooth': { type, eigenvalues, method:'eigenvalue', jacobian }.
Otherwise: { type, contracting, expanding, sampled, flow, method:'numeric' }
(see numericStability).
|
classifyAll |
classifyAll(game, dynamic, points) |
Convenience over an array of rest points (as returned by
restPoints): returns each point augmented with a
stability field, i.e. { reduced, state, stability }[].
|
classifyEigen |
classifyEigen(J, eps = 1e-6, imEps = 1e-3) |
Classify directly from a Jacobian J via its eigenvalues.
Returns { type, eigenvalues }. type is
'saddle' (mixed-sign real parts), 'source' /
'spiral source' (all positive, real or complex),
'sink' / 'spiral sink' (all negative), else
'centre'. The 'spiral' label needs
|Im λ| > imEps·|λ|max —
multiple roots split under finite-difference noise like noise⅓,
so slower rotation is read as numerically real.
|
jacobian |
jacobian(game, dynamic, restState) |
The Jacobian of the reduced field at the rest point, by central differences (step 1e-6). A D×D matrix, D the reduced dimension. |
numericStability |
numericStability(game, dynamic, restState, opts) |
The kink-robust test, in two stages. Stage one samples the
radial component of the field over a spread of feasible unit
directions; a unanimous reading (all contracting / all expanding /
all zero) settles the verdict immediately. Mixed signs prove nothing —
rotation masks contraction (BNN at the RPS centroid spirals in
while moving radially outward in some directions at every instant) —
so stage two flows each probe forward and judges it on the
last fifth of its run: contracted (stays inside
shrink·eps), escaped (stays outside
grow·eps) or undecided. All contracted →
'sink'; any escape → 'saddle' when
genuinely contracting directions exist, else 'source';
mostly undecided → 'centre', which therefore means
"no clear local verdict" — closed orbits and sub-exponential
convergence (e.g. the BR dynamic's ~1/t approach on Matching Pennies)
are not distinguished. Options: eps (nudge size, default
0.015), dirs (26),
deadband (1e-7), flowT (stage-two
horizon, 100), flowDt (0.1),
flowSamples (50), shrink
(0.6), grow (1.7). Returns
{ type, contracting, expanding, sampled, flow }, where
flow is { contracted, escaped, undecided }
when stage two ran and null otherwise.
|
import { restPoints, classify, classifyAll, jacobian, replicator, standardGames } from './src/index.js';
const hd = standardGames.hawkDove();
const rps = restPoints(hd, replicator);
classifyAll(hd, replicator, rps);
// → [{ reduced, state, stability: { type, eigenvalues, method, jacobian } }, …]
classify(hd, replicator, rps[0].state).type; // e.g. 'saddle'
jacobian(hd, replicator, rps[0].state); // → 2×2 matrix
contour.js — scalar fields and the Lyapunov exponent
Scalar functions of the state, used by the renderers' speed heat maps, plus the maximum Lyapunov exponent — the average divergence rate of nearby orbits: negative at a sink, ≈0 on a conservative cycle, positive for chaos.
| Export | Signature | Returns / meaning |
|---|---|---|
speedField |
speedField(game, dynamic) |
A function state → ‖V(state)‖2 — the Euclidean speed of the dynamic. |
l1SpeedField |
l1SpeedField(game, dynamic) |
As above with the L¹ norm. |
maxSpeedField |
maxSpeedField(game, dynamic) |
As above with the L∞ norm. |
maxLyapunovExponent |
maxLyapunovExponent(game, dynamic, x0, opts) |
The maximum Lyapunov exponent along the orbit from x0
(per unit dynamical time), by the two-trajectory Benettin method: a
companion orbit is started a small separation away, the log growth of
the separation is accumulated each step, and the companion is pulled
back in. The companion is always placed by clamping onto the simplex
and measuring the separation actually achieved, so boundary
attractors are handled honestly (a vertex sink reads its true
eigenvalue, ≈−1 for Coordination, rather than a clamp
artefact). Options: steps (default 2000),
dt (0.05), d0 (nominal
separation, 1e-7). The estimate is
parameter-dependent at the second decimal — read the sign and the
order of magnitude, not the last digit.
|
import { maxLyapunovExponent, replicator, standardGames } from './src/index.js';
const good = standardGames.goodRPS();
maxLyapunovExponent(good, replicator, [[0.45, 0.3, 0.25]], { steps: 4000 });
// → ≈ −1/6, the real part of the centroid's Jacobian eigenvalues
geometry.js — state → drawable shape (deep / bundle only)
The bridge from a population state to a drawable shape. Every Dynamo
notebook works on a product of simplices fixed by dims; the
mathematics is identical across them, only the picture differs. A geometry
maps a full state to a point in a canonical world space (2-D or 3-D),
supplies the frame (vertices, edges, faces) and the strategy labels, and
yields an interior grid of states for sampling the vector field.
geometryFor(dims)
Resolves the geometry for a dims configuration. The five
Dynamo cases are handled explicitly; a single n-strategy
population falls back to the simplex (triangle for 3, tetrahedron for 4).
Throws for an unregistered dims.
dims | Space | Shape | embedDim | reducedDim |
|---|---|---|---|---|
[2,2] | Δ¹ × Δ¹ | unit square | 2 | 2 |
[3] | Δ² | triangle | 2 | 2 |
[4] | Δ³ | tetrahedron | 3 | 3 |
[3,2] | Δ² × Δ¹ | triangular prism | 3 | 3 |
[2,2,2] | (Δ¹)³ | cube | 3 | 3 |
The geometry object
| Field | Type | Meaning |
|---|---|---|
dims | number[] | The strategy counts this geometry was built for. |
reducedDim | number | Dimension of the reduced (integrated, root-found) space. |
embedDim | number | Dimension of the world space the shape is drawn in (2 or 3). The renderers dispatch on this. |
bounds | { min, max } | World-space bounding box (each min/max a length-embedDim vector). |
vertices | { point, state }[] | The shape's corners: point a world coordinate, state the full state there. |
edges | [i, j][] | Index pairs into vertices giving the frame edges. |
faces | number[][] | Index tuples into vertices giving the (translucent, depth-sorted) faces; empty for the 2-D shapes and the cube. |
toPoint(state) | fn → number[] | Map a full state to a world coordinate. |
fromPoint(p) | fn → number[][] | null | Inverse: map a world coordinate back to a full state, or null if outside the shape. Present on the square and triangle (used for contour shading); absent on tetrahedron, cube and prism. |
interior(n) | fn → number[][][] | An n-resolution grid of interior full states, for sampling the vector field and seeding trajectories. |
labels(game) | fn → label objects | Strategy labels positioned in world space: { text, point, align, rotate? }, where align is 'start' | 'middle' | 'end'. |
The module also exports discSimplex(n, d, interior = false) —
the discrete simplex of every length-d probability vector whose
entries are multiples of 1/n, optionally dropping the boundary.
import { geometryFor } from './src/geometry.js';
const g = geometryFor([3]); // the triangle
g.embedDim; // → 2
g.toPoint([[1/3, 1/3, 1/3]]); // → world coordinate of the centre
g.interior(6).length; // interior sample states
render-2d.js — 2-D phase portraits (deep / bundle only)
Produces a self-contained SVG string for the two-dimensional shapes (square,
triangle) from a game and a dynamic: a vector field, a sheaf of
trajectories, rest points coloured by stability, the frame and the strategy
labels. Being a pure string builder it runs in Node (for tests and static
export) and in the browser alike. The geometry is taken from
game.dims unless one is supplied; a game is only ever drawn on
its own shape.
phasePortraitSVG(game, dynamic, opts) → SVG string
| Option | Type | Default | Meaning |
|---|---|---|---|
width | number | 440 | SVG width in pixels. |
height | number | 440 | SVG height in pixels. |
padding | number | 46 | Inner margin between the shape and the edge. |
vectorField | boolean | true | Draw the vector-field arrows. |
vectorGrid | number | 11 | Resolution of the vector-field sampling grid. |
trajectories | boolean | true | Draw a sheaf of trajectories from an interior grid. |
trajGrid | number | 5 | Resolution of the trajectory-seed grid. |
tMax | number | 16 | Trajectory integration horizon. |
dt | number | 0.05 | Trajectory integration step. |
arrowAt | number | number[] | false | [0.5] | Arrowhead placement along each trajectory, as arc-length fraction(s) in [0,1] (0.5 = midpoint). A number, an array for several, or false for none. |
arrowSize | number | 1 | Arrowhead size multiplier. At 1 the head is a filled Latex-style arrowhead (sharp tip, gently concave sides, flat back — as TikZ's -{Latex}) sized to about 2.1% of the drawing box's smaller side, centred on the arrowAt fraction; scale up or down from there. |
restPoints | boolean | {state, type}[] | true | Find and draw rest points, glyphed by stability type (only when reducedDim === 2; the auto-found set is capped at 30 to avoid a degenerate cloud). Passing a precomputed {state, type}[] draws those instead, in full, — for host pages that re-render often (sliders, drags) and cache the find-and-classify step. false suppresses. |
contour | boolean | false | Shade the background by speed ‖V‖ (requires the geometry's fromPoint; available on square and triangle). |
contourRes | number | 48 | Resolution of the contour-shading grid. |
contourGrid | boolean | false | Stroke the heat-map cell boundaries (visible tessellation; square and triangular cells). |
basins | boolean | false | Fill the basins of attraction as smooth clipped regions (via basinRegions; requires the geometry's fromPoint and at least two attractors). |
basinsRes | number | 64 | Sampling-grid resolution for the basin boundaries (higher → finer separatrices, slower). |
speedColour | boolean | false | Tint the background trajectories by local speed (a heat map of flow) instead of a flat colour. |
extraTrajectories | number[][][] | [] | Extra user-dropped seed states, drawn highlighted on top. |
title | string | null | Optional title drawn at the top. |
geometry | geometry object | from game.dims | Override the geometry. Must have embedDim === 2, else an error is thrown. |
A speed legend is added when contour or speedColour is on. Rest-point glyphs: filled disc = sink/spiral sink; hollow disc = source/spiral source; ringed disc = saddle; rotated square = centre.
Also exported
| Export | Signature | Returns / meaning |
|---|---|---|
viewTransform |
viewTransform(geometry, width, height, padding) |
The world→screen transform fitting the geometry's bounds into the box, preserving aspect ratio (so triangles stay equilateral). Returns { toScreen, toWorld, scale } — two coordinate maps and the scale factor. |
PALETTE |
object | The renderer's warm scholarly colour palette: paper, ink, frame, accent, fieldSlow, fieldFast, traj, userTraj, label. |
import { phasePortraitSVG } from './src/render-2d.js';
import { standardGames, replicator } from './src/index.js';
const svg = phasePortraitSVG(standardGames.matchingPennies(), replicator, {
width: 480, height: 480, contour: true, title: 'Matching Pennies — replicator',
});
// svg is a standalone <svg>…</svg> string
render-3d.js — 3-D phase portraits (deep / bundle only)
The same idea for the three-dimensional shapes (tetrahedron, cube, triangular prism). The reduced state space is three-dimensional, so the renderer draws an orthographic projection the host page can rotate by varying the camera angles. Faces are translucent and depth-sorted; the frame, vector field and trajectories sit on top. Output is again a self-contained SVG string, re-rendered per frame while dragging.
phasePortrait3dSVG(game, dynamic, opts) → SVG string
| Option | Type | Default | Meaning |
|---|---|---|---|
width | number | 460 | SVG width in pixels. |
height | number | 460 | SVG height in pixels. |
padding | number | 40 | Inner margin. |
azimuth | number (radians) | 0.6 | Camera azimuth (rotation about the vertical). |
elevation | number (radians) | 0.34 | Camera elevation (tilt). |
vectorField | boolean | true | Draw the (depth-sorted) vector-field arrows. |
vectorGrid | number | per shape | Resolution of the vector-field sampling grid. Defaults to the geometry's own — tetrahedron 8 (35 arrows), cube 5 (64), prism 5 (24) — since interior-point counts differ sharply per shape; an explicit value overrides. |
trajectories | boolean | true | Draw a sheaf of trajectories from an interior grid. |
trajGrid | number | per shape | Resolution of the trajectory-seed grid. Defaults to the geometry's own — tetrahedron 7 (20 seeds), cube 4 (27), prism 5 (24); an explicit value overrides. |
tMax | number | 18 | Trajectory integration horizon. |
dt | number | 0.05 | Trajectory integration step. |
arrowAt | number | number[] | false | [0.5] | Arrowhead placement along each trajectory, as arc-length fraction(s) in [0,1] (0.5 = midpoint). A number, an array for several, or false for none. |
arrowSize | number | 1 | Arrowhead size multiplier. At 1 the head is a filled Latex-style arrowhead (sharp tip, gently concave sides, flat back — as TikZ's -{Latex}) sized to about 2.1% of the drawing box's smaller side, centred on the arrowAt fraction; scale up or down from there. |
faces | boolean | true | Draw the translucent depth-sorted faces (those the geometry defines). |
restPoints | boolean | {state, type}[] | true | Find and draw rest points, glyphed by stability (the auto-found set is capped at 22). A precomputed {state, type}[] draws those instead, in full, — the showcase caches this across drag frames. false suppresses. |
speedColour | boolean | false | Tint background trajectories by local speed instead of a flat colour. |
extraTrajectories | number[][][] | [] | Extra user-dropped seed states, highlighted on top. |
title | string | null | Optional title at the top. |
geometry | geometry object | from game.dims | Override the geometry. Must have embedDim === 3, else an error is thrown. |
Also exported
| Export | Signature | Returns / meaning |
|---|---|---|
camera3d |
camera3d(geometry, width, height, padding, az, el) |
An orthographic camera mapping world (3-D) to screen (2-D) with a depth for sorting. Returns { project, radius, centroid }, where project(point) → { sx, sy, depth }. |
import { phasePortrait3dSVG } from './src/render-3d.js';
import { standardGames, replicator } from './src/index.js';
const svg = phasePortrait3dSVG(standardGames.cyclic4(), replicator, {
azimuth: 0.9, elevation: 0.4, title: 'Four-strategy cycle',
});
colormap.js — speed heat-map colours (deep / bundle only)
Sequential colour scales for speed heat maps. Three named scales are built
in: 'ember' (the default — pale cream to deep ember),
'depth' (a cooler alternative) and 'mono'.
| Export | Signature | Returns / meaning |
|---|---|---|
heatColor |
heatColor(t, scale = 'ember') |
Hex colour for a normalised speed t ∈ [0,1] (clamped; a non-finite t reads as 0, so a NaN speed degrades to "slow" rather than throwing). scale is one of 'ember' | 'depth' | 'mono'; an unknown name falls back to ember. |
scaleStops |
scaleStops(scale = 'ember') |
A copy of the named scale's list of hex stops, for drawing legends. |
lerpHex |
lerpHex(a, b, t) |
Linear interpolation between two hex colours a and b at t, as a hex string. |
import { heatColor, scaleStops, lerpHex } from './src/colormap.js';
heatColor(0); // → '#f6f1e7' (slow, pale cream)
heatColor(1); // → '#5e241f' (fast, deep ember)
scaleStops('depth'); // → ['#f3f1ee', …, '#2a2540']
lerpHex('#000000', '#ffffff', 0.5); // → '#808080'
prng.js — seeded randomness
All randomness in the library flows through one seedable generator
(xoshiro128**), never Math.random(). The stochastic dynamics
are reproducible: the same seed always produces the same picture. A seed may
be an integer or a memorable string (hashed with FNV-1a).
The Random class
Construct with a seed for an independent stream: new Random(seed = 0).
| Method | Signature | Returns / meaning |
|---|---|---|
seed | seed(seed = 0) | Reseed in place (integer or string). Returns this. |
random | random() | Uniform real in [0, 1). |
uniform | uniform(a = 0, b = 1) | Uniform real in [a, b). |
int | int(min, max) | Uniform integer in [min, max], inclusive. |
bool | bool(p = 0.5) | A coin flip, true with probability p. |
pick | pick(arr) | A uniformly chosen element of arr (or undefined if empty). |
shuffle | shuffle(arr) | Fisher–Yates shuffle in place; returns the same array. |
sample | sample(arr, n) | n elements sampled without replacement (a fresh array). |
normal | normal(mu = 0, sigma = 1) | A standard-normal variate, scaled and shifted (Box–Muller). |
simplex | simplex(n) | A point drawn uniformly from the (n−1)-simplex — a random mixed strategy or population over n strategies (Dirichlet(1,…,1)). |
stickBreak | stickBreak(n) | A point on the (n−1)-simplex by stick-breaking in a shuffled order. Not uniform: favours sparse mixtures, so it samples the edges and faces of the simplex. |
There is also a low-level next() returning the raw 32-bit unsigned integer.
Module-level helpers (a shared default generator)
| Export | Signature | Returns / meaning |
|---|---|---|
setSeed | setSeed(seed) | Seed the shared default generator; call once at the start of a simulation. Returns the generator. |
random | random() | Uniform real in [0, 1) from the shared default generator. |
defaultRandom | defaultRandom() | The shared default generator (e.g. to pass explicitly to a dynamic). |
import { Random, setSeed, random } from './src/index.js';
const rng = new Random('matching-pennies');
rng.uniform(0, 1);
rng.int(1, 6);
rng.shuffle([1, 2, 3, 4]);
setSeed(42);
random(); // reproducible from the shared default generator
state.js — the state model
The maps between the full representation (every probability, summing to one
per population), the reduced representation (one coordinate dropped per
population — what is integrated and drawn) and the flat representation (a
single vector). All are general over dims.
| Export | Signature | Returns / meaning |
|---|---|---|
uniformState | uniformState(dims = [2,2]) | The uniform (barycentre) full state for the given strategy counts. |
cloneState | cloneState(state) | A deep copy of a full state. |
dimsOf | dimsOf(state) | The strategy counts of a state, e.g. [2, 2]. |
normaliseState | normaliseState(state) | Project a drifted state back onto the product of simplices: clamp negatives to zero and renormalise each population to sum to one. A population whose sum is not a finite positive number (a dynamic divided by zero, say) is returned as all-NaN — visibly broken, never replaced by a valid-looking state. |
isOnSimplex | isOnSimplex(state, tol = 1e-9) | Whether the state lies on the product of simplices within tol. |
fullToReduced | fullToReduced(state) | Drop the last coordinate of each population; [[x1,x2],[y1,y2]] → [x1, y1]. |
reducedToFull | reducedToFull(reduced, dims = [2,2]) | Restore each dropped coordinate as 1 − Σ; [x1, y1] → [[x1, 1−x1], [y1, 1−y1]]. |
flattenState | flattenState(state) | Flatten to one vector, e.g. [x1, x2, y1, y2]. |
unflattenState | unflattenState(flat, dims = [2,2]) | The inverse of flattenState for the given counts. |
import { uniformState, fullToReduced, reducedToFull } from './src/index.js';
uniformState([3]); // → [[1/3, 1/3, 1/3]]
fullToReduced([[0.7, 0.3], [0.4, 0.6]]); // → [0.7, 0.4]
reducedToFull([0.7, 0.4]); // → [[0.7, 0.3], [0.4, 0.6]]
linalg.js — the linear-algebra kit (deep / bundle only)
The small, dependency-free kit the rest of the library leans on. Vectors are
plain number[]; matrices are number[][] in
row-major order.
| Export | Signature | One-line description |
|---|---|---|
dot | dot(u, v) | Dot product of two equal-length vectors. |
matVec | matVec(M, v) | Matrix · vector (M is m×n, v length n). |
vecMat | vecMat(v, M) | Row-vector · matrix. |
transpose | transpose(M) | Matrix transpose. |
matMul | matMul(A, B) | Matrix · matrix. |
add | add(u, v) | Element-wise vector sum. |
sub | sub(u, v) | Element-wise vector difference. |
scale | scale(s, v) | Scale a vector by a scalar. |
diag | diag(v) | Diagonal matrix from a vector. |
identity | identity(n) | The n×n identity matrix. |
constantMatrix | constantMatrix(rows, cols, val = 0) | A rows×cols matrix filled with val. |
constantVector | constantVector(n, val = 0) | A length-n vector filled with val. |
norm | norm(v, p = 2) | Vector p-norm; p may be 1, 2 or Infinity. |
solve2 | solve2(A, b) | Solve the 2×2 system A·z = b by Cramer's rule, or null if singular. |
solveLinear | solveLinear(A, b) | Solve a general linear system by Gauss–Jordan with partial pivoting, or null if singular. |
eig2 | eig2(A) | Eigenvalues of a 2×2 matrix, as [{re, im}, {re, im}]. |
eigenvalues | eigenvalues(A) | Eigenvalues of a small (n ≤ 3) matrix, as {re, im}[]; throws for n > 3. |
jacobianFD | jacobianFD(f, x, h = 1e-6) | Central-difference Jacobian of f: Rⁿ → Rᵐ at x; an m×n matrix. |
The bundle, and a note on surfaces
dist/dynamo.js is generated by the project's hand-rolled
build.js. It wraps each module in its own function scope and
wires them together through a tiny in-bundle registry, then merges every
module's exports into a single Dynamo object assigned on
window (or globalThis), with a CommonJS
module.exports fallback. Because the merge spans all twelve
modules, the global is the most complete surface — the renderers, the
geometry, the colour maps and the linear-algebra helpers are all present as
Dynamo.<name>, even though the ES barrel
(src/index.js) intentionally exposes only the engine.
The same files are importable as ES modules. Use the barrel for the engine;
deep-import render-2d.js, render-3d.js,
geometry.js, colormap.js and
linalg.js (or gameCatalog from
game.js) when you need them — or simply use the bundle, where
everything lives together.