Dynamo.js
Documentation

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:

dimspopulations × strategiesspaceshapenotebook
[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 dims fix 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) → velocity that 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:

dimspopulations × strategiesstate shapedrawn 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)). For dims = [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). Use fullToReduced / reducedToFull; both are general over dims.
  • Flat state — the full state flattened to a single vector, [x₁, x₂, y₁, y₂]. The integrator works on this form. See flattenState / 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:

constructorpayoff fieldused for
Game.normalForm(A, B)F[1] = A·y, F[2] = Bᵀ·xbimatrix games (square)
Game.linear(M)F[1] = M₁₁·x + M₁₂·y, F[2] = M₂₁·x + M₂₂·ygeneral linear multi-population
Game.symmetric(A)F(x) = A·x (one population)the S-series (triangle, tetrahedron)
Game.trilinear(A)three-body cyclic trilinear formthe 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: Σⱼ ẋₚⱼ = 0 for 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

APIformularest pointsstability test
replicatorẋₚⱼ = xₚⱼ · F̂ₚⱼAutomaticeigenvalue
maynardSmithẋₚ = (1/F̄ₚ) · replicatorₚAutomaticeigenvalue
logit(η)softmax(Fₚ/η)ⱼ − xₚⱼAutomaticeigenvalue
iLogit(η)xₚⱼ·e^{Fₚⱼ/η} / Σₖ xₚₖ·e^{Fₚₖ/η} − xₚⱼAutomaticradial
brlogit(0.001)Nashradial
exactBRBR(x) − x (ties split evenly)Nashradial
temperedLogit(η, Q)Zusai tempered logit of Q(F)Automaticradial
temperedBR(Q)temperedLogit(0.001, Q)Nashradial
bnn[F̂ₚⱼ]₊ − xₚⱼ · Σₖ[F̂ₚₖ]₊Nashradial
smithΣₖ xₚₖ[Fₚⱼ−Fₚₖ]₊ − xₚⱼ Σₖ[Fₚₖ−Fₚⱼ]₊Nashradial
excessPayoffσ̃ₚⱼ − xₚⱼ·Σₖ σ̃ₚₖ, σ̃ = ([F̂]₊)²Nashradial
projectiontangent-cone projection of FNashradial
sampleBR(k)E[BR(k-sample)] − x (exact)Automaticeigenvalue
selMut(M)Mᵀ·diag(x)·F − (x·F)·xAutomaticradial
combine(A, B, wA, wB)wA·A + wB·BNashradial

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 the eigenvalues and the jacobian.
  • 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 is 1 − Σ), 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 = 0 for every population).
  • dims is 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.

ModuleIn the index.js barrel?On the Dynamo global?
prng.jsYesYes
linalg.jsNo (deep / bundle)Yes
state.jsYesYes
game.jsPartly: Game, standardGames, projectionMatrix (not gameCatalog)Yes (incl. gameCatalog)
dynamics.jsYesYes
integrator.jsYesYes
geometry.jsNo (deep / bundle)Yes
rest-points.jsYesYes
stability.jsYesYes
colormap.jsNo (deep / bundle)Yes
render-2d.jsNo (deep / bundle)Yes
render-3d.jsNo (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

SignatureParametersField 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

MethodReturnsMeaning
payoff(state)number[][]The payoff field F(x): payoff to each strategy of each population.
mean(state)number[]Average payoff per population, p = Σj xp,j·Fp,j — one scalar per population.
excess(state)number[][]Excess payoff 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

ExportSignatureReturns / 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
Keydims / shapeGame and behaviour
prisonersDilemma[2,2] squarePrisoner's Dilemma — strict dominance; the single sink is mutual defection. Labels Cooperate/Defect.
coordination[2,2] squareCoordination — two pure sinks, an interior saddle. Labels A/B.
stagHunt[2,2] squareStag Hunt — payoff- vs risk-dominant coordination. Labels Stag/Hare.
hawkDove[2,2] squareHawk–Dove (Chicken) — anti-coordination, an interior ESS. Labels Hawk/Dove.
matchingPennies[2,2] squareMatching Pennies — zero-sum, a centre: replicator orbits are closed. Labels Heads/Tails.
rockPaperScissors[3] triangleRock–Paper–Scissors — a conservative cycle around the centre.
goodRPS[3] triangleGood RPS — net positive cycle; the centre is an attractor.
badRPS[3] triangleBad RPS — net negative cycle; the centre repels, orbits spiral out.
coordination3[3] triangleThree-strategy coordination — three pure sinks.
zeeman[3] triangleZeeman's game — an interior fixed point and a limit cycle.
young[3] triangleYoung's game — an interior equilibrium reached from much of the simplex.
cyclic4[4] tetrahedronFour-strategy cyclic game (each strategy beats the next).
coordination4[4] tetrahedronFour-strategy coordination — four pure sinks at the tetrahedron's vertices.
outsideOption[3,2] prismCoordination with an outside option (the notebook's 3×2 preset). Labels A/B/Out vs a/b.
threeWayCoordination[2,2,2] cubeThree-way coordination: each population rewarded for matching the others. Trilinear.
threeWayAnticoordination[2,2,2] cubeThree-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)

ExportFormulacharacterisation (rp / s / ao)
replicator&xdot;p,j = xp,j·F̂p,jAutomatic / 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 plotsAutomatic / Smooth / false
bnnBrown–von Neumann–Nash: &xdot;p,j = [F̂p,j]+ − xp,j·Σk[F̂p,k]+Nash / ESS / false
smithPairwise 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
projectionClosest-point projection of the payoff onto the simplex tangent cone (active-set); for an interior state, (I−(1/n)J)·FpNash / ESS / false
brBest 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)

ExportSignatureField / meaningcharacterisation (rp / s / ao)
logitlogit(eta)&xdot;p,j = softmax(Fp/η)j − xp,j (stable soft-max)Automatic / Smooth / false
iLogitiLogit(eta)Imitative logit: &xdot;p,j = xp,je^{Fp,j/η} / Σk xp,ke^{Fp,k/η} − xp,jAutomatic / HiperESS / false
temperedLogittemperedLogit(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
temperedBRtemperedBR(Q = v => v)Tempered best response = temperedLogit(0.001, Q)Nash / Unknown / false
sampleBRsampleBR(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
selMutselMut(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
combinecombine(dynA, dynB, wA = 0.5, wB = 0.5)Weighted combination of two fields: wA·dynA + wB·dynB.Nash / Unknown / false

Helpers and the registry

ExportSignatureReturns / 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)

OptionTypeDefaultMeaning
tMaxnumber20Horizon in dynamical time (integration stops when |t| ≥ tMax).
dtnumber0.05Step size for RK4; the initial step for RK45.
method'rk4' | 'rk45''rk4'Fixed-step RK4, or adaptive Dormand–Prince RK45 with error control.
directionnumber1+1 integrates forward in time, −1 backward.
tolnumber1e-6Per-step error tolerance (RK45 only; ignored by RK4).
reprojectbooleantrueRe-project (normalise) onto the product of simplices after each step.
maxStepsnumber20000Safety cap on the number of steps.

Returns { times, states, points, completed }:

  • times: number[] — the sampled times, starting at 0.
  • states: number[][][] — the full state at each time.
  • points: number[][] — the reduced plotting coordinates, the xp,1 (first component) of each population at each time.
  • completed: booleanfalse when the run hit maxSteps before reaching tMax. The main way to get there: RK45's error control collapses the step at a discontinuous field (exactBR at a best-reply tie) and the trajectory silently truncates — prefer rk4 for exactBR, 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.

ExportSignatureReturns / 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'.

ExportSignatureReturns / 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.

ExportSignatureReturns / 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.

dimsSpaceShapeembedDimreducedDim
[2,2]Δ¹ × Δ¹unit square22
[3]Δ²triangle22
[4]Δ³tetrahedron33
[3,2]Δ² × Δ¹triangular prism33
[2,2,2](Δ¹)³cube33

The geometry object

FieldTypeMeaning
dimsnumber[]The strategy counts this geometry was built for.
reducedDimnumberDimension of the reduced (integrated, root-found) space.
embedDimnumberDimension 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.
facesnumber[][]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[][] | nullInverse: 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 objectsStrategy 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

OptionTypeDefaultMeaning
widthnumber440SVG width in pixels.
heightnumber440SVG height in pixels.
paddingnumber46Inner margin between the shape and the edge.
vectorFieldbooleantrueDraw the vector-field arrows.
vectorGridnumber11Resolution of the vector-field sampling grid.
trajectoriesbooleantrueDraw a sheaf of trajectories from an interior grid.
trajGridnumber5Resolution of the trajectory-seed grid.
tMaxnumber16Trajectory integration horizon.
dtnumber0.05Trajectory integration step.
arrowAtnumber | 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.
arrowSizenumber1Arrowhead 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.
restPointsboolean | {state, type}[]trueFind 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.
contourbooleanfalseShade the background by speed ‖V‖ (requires the geometry's fromPoint; available on square and triangle).
contourResnumber48Resolution of the contour-shading grid.
contourGridbooleanfalseStroke the heat-map cell boundaries (visible tessellation; square and triangular cells).
basinsbooleanfalseFill the basins of attraction as smooth clipped regions (via basinRegions; requires the geometry's fromPoint and at least two attractors).
basinsResnumber64Sampling-grid resolution for the basin boundaries (higher → finer separatrices, slower).
speedColourbooleanfalseTint the background trajectories by local speed (a heat map of flow) instead of a flat colour.
extraTrajectoriesnumber[][][][]Extra user-dropped seed states, drawn highlighted on top.
titlestringnullOptional title drawn at the top.
geometrygeometry objectfrom game.dimsOverride 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

ExportSignatureReturns / 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

OptionTypeDefaultMeaning
widthnumber460SVG width in pixels.
heightnumber460SVG height in pixels.
paddingnumber40Inner margin.
azimuthnumber (radians)0.6Camera azimuth (rotation about the vertical).
elevationnumber (radians)0.34Camera elevation (tilt).
vectorFieldbooleantrueDraw the (depth-sorted) vector-field arrows.
vectorGridnumberper shapeResolution 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.
trajectoriesbooleantrueDraw a sheaf of trajectories from an interior grid.
trajGridnumberper shapeResolution 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.
tMaxnumber18Trajectory integration horizon.
dtnumber0.05Trajectory integration step.
arrowAtnumber | 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.
arrowSizenumber1Arrowhead 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.
facesbooleantrueDraw the translucent depth-sorted faces (those the geometry defines).
restPointsboolean | {state, type}[]trueFind 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.
speedColourbooleanfalseTint background trajectories by local speed instead of a flat colour.
extraTrajectoriesnumber[][][][]Extra user-dropped seed states, highlighted on top.
titlestringnullOptional title at the top.
geometrygeometry objectfrom game.dimsOverride the geometry. Must have embedDim === 3, else an error is thrown.

Also exported

ExportSignatureReturns / 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'.

ExportSignatureReturns / 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).

MethodSignatureReturns / meaning
seedseed(seed = 0)Reseed in place (integer or string). Returns this.
randomrandom()Uniform real in [0, 1).
uniformuniform(a = 0, b = 1)Uniform real in [a, b).
intint(min, max)Uniform integer in [min, max], inclusive.
boolbool(p = 0.5)A coin flip, true with probability p.
pickpick(arr)A uniformly chosen element of arr (or undefined if empty).
shuffleshuffle(arr)Fisher–Yates shuffle in place; returns the same array.
samplesample(arr, n)n elements sampled without replacement (a fresh array).
normalnormal(mu = 0, sigma = 1)A standard-normal variate, scaled and shifted (Box–Muller).
simplexsimplex(n)A point drawn uniformly from the (n−1)-simplex — a random mixed strategy or population over n strategies (Dirichlet(1,…,1)).
stickBreakstickBreak(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)

ExportSignatureReturns / meaning
setSeedsetSeed(seed)Seed the shared default generator; call once at the start of a simulation. Returns the generator.
randomrandom()Uniform real in [0, 1) from the shared default generator.
defaultRandomdefaultRandom()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.

ExportSignatureReturns / meaning
uniformStateuniformState(dims = [2,2])The uniform (barycentre) full state for the given strategy counts.
cloneStatecloneState(state)A deep copy of a full state.
dimsOfdimsOf(state)The strategy counts of a state, e.g. [2, 2].
normaliseStatenormaliseState(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.
isOnSimplexisOnSimplex(state, tol = 1e-9)Whether the state lies on the product of simplices within tol.
fullToReducedfullToReduced(state)Drop the last coordinate of each population; [[x1,x2],[y1,y2]] → [x1, y1].
reducedToFullreducedToFull(reduced, dims = [2,2])Restore each dropped coordinate as 1 − Σ; [x1, y1] → [[x1, 1−x1], [y1, 1−y1]].
flattenStateflattenState(state)Flatten to one vector, e.g. [x1, x2, y1, y2].
unflattenStateunflattenState(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.

ExportSignatureOne-line description
dotdot(u, v)Dot product of two equal-length vectors.
matVecmatVec(M, v)Matrix · vector (M is m×n, v length n).
vecMatvecMat(v, M)Row-vector · matrix.
transposetranspose(M)Matrix transpose.
matMulmatMul(A, B)Matrix · matrix.
addadd(u, v)Element-wise vector sum.
subsub(u, v)Element-wise vector difference.
scalescale(s, v)Scale a vector by a scalar.
diagdiag(v)Diagonal matrix from a vector.
identityidentity(n)The n×n identity matrix.
constantMatrixconstantMatrix(rows, cols, val = 0)A rows×cols matrix filled with val.
constantVectorconstantVector(n, val = 0)A length-n vector filled with val.
normnorm(v, p = 2)Vector p-norm; p may be 1, 2 or Infinity.
solve2solve2(A, b)Solve the 2×2 system A·z = b by Cramer's rule, or null if singular.
solveLinearsolveLinear(A, b)Solve a general linear system by Gauss–Jordan with partial pivoting, or null if singular.
eig2eig2(A)Eigenvalues of a 2×2 matrix, as [{re, im}, {re, im}].
eigenvalueseigenvalues(A)Eigenvalues of a small (n ≤ 3) matrix, as {re, im}[]; throws for n > 3.
jacobianFDjacobianFD(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.