/rideshare-sim/ is a live, animated agent-based market simulation of two competing rideshare platforms. This blog covers how it is built and how it reaches the page.

Run it on your own browser here
TLDR
The simulation is a client-side program written in TypeScript. It is compiled into JavaScript, and then the JavaScript runs in the reader’s browser. So nothing runs on a server: the blog host the algorithm, hands them to user’s local computer to do the computation.
The program
The code is about 5,000 lines, split into an engine and a user interface. They meet at one object.
src/engine/
types.ts state, agent, and config type definitions
config.ts default parameters, with sourcing for each
prng.ts seeded pseudo-random number generator
search.ts agents' cutoff rules — when to search, when to accept
simulation.ts the state machine: one tick at a time
src/components/
LiveMarketViz.tsx the animation: two lanes of riders and drivers
Charts.tsx prices, wages, queue lengths, match share
ConfigPanel.tsx sliders for every parameter, and the equations
QueuePanel.tsx running counts per platform
Controls.tsx play, pause, step, reset, speed
The engine exposes a class, SimulationRunner. It holds the market state privately and offers three operations: step() advances the world by one tick, snapshot() returns a read-only copy of the current state, and reset() starts over from a given configuration. The interface never reaches inside the model — it calls step(), receives a snapshot, and draws it.
Time is discrete. One tick is 30 simulated seconds. Each tick, the engine draws new rider and driver arrivals, assigns them a starting platform, lets waiting agents decide whether to search the other platform or abandon the market, matches whoever can be matched, and moves prices in response to the resulting queue imbalance. Randomness comes from prng.ts, a seeded generator, which is what makes a run reproducible: the same parameters and the same seed produce the same run, every time, on any machine.
A script in scripts/ stores a baseline of 130 recorded runs, and npm run baseline:check re-runs them and asserts the results are bit-identical to the stored values. It is a regression test for a stochastic model — refactoring is allowed to change the code, but not the numbers.
The user interface
The interface is built with React, a JavaScript library for describing what the screen should look like as a function of the current data, and redrawing when that data changes. The charts use Recharts, a plotting library built on React. The animation in LiveMarketViz is hand-written SVG rather than a charting library, because the agents move.
The loop is unremarkable: a timer calls step() at the chosen speed, stores the resulting snapshot in React state, and React re-renders the parts of the screen that changed.
Platforms are labelled Platform 1 and Platform 2 rather than named after real companies. The parameter defaults are calibrated against public figures for the US market — take rates, market share, arrival rates — and the sources are cited in the comments of config.ts, but the model itself is about market structure, not any particular firm.
From source code to a web page
Browsers do not run TypeScript, and they do not fetch fifteen separate files happily. A build tool called Vite handles both problems. It compiles the TypeScript to JavaScript, then bundles it: every source file, plus the imported libraries, is combined into a single JavaScript file and minified — comments stripped, variable names shortened. The result is 656 kB, or 195 kB compressed in transit, which is one HTTP request and a fraction of a second.
The blog itself is generated by Hugo, a static site generator. Hugo reads Markdown files, applies templates, and writes finished HTML into a folder called public/; that folder is what the host serves. Hugo also has a passthrough convention: anything placed in static/ is copied into the output verbatim. A file at static/rideshare-sim/index.html becomes /rideshare-sim/index.html on the live site, with no configuration.
So Vite is pointed at that folder directly. The build is one line in package.json:
"build:hugo": "tsc -b && vite build --base=./ --outDir ../static/rideshare-sim"
tsc -b type-checks first and aborts on error. --outDir sends the output into Hugo’s passthrough folder. --base=./ makes the generated index.html refer to its own assets by relative path, so the bundle works from whatever URL it is served at.
Where the build happens
The build does not run on the author’s laptop. It runs on GitHub’s servers, described by a file at .github/workflows/hugo.yaml. That file is a recipe GitHub Actions follows after every push, on a fresh temporary Linux machine.
The recipe already installed Hugo, checked out the repository, ran Hugo, and published the result. Because a fresh machine begins with nothing, anything else the build needs must be named explicitly. Two steps do that:
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version-file: rideshare-sim/.nvmrc
cache: npm
cache-dependency-path: rideshare-sim/package-lock.json
- name: Build rideshare-sim
working-directory: rideshare-sim
run: |
npm ci
npm run build:hugo
Node.js is JavaScript running outside a browser; it is what allows Vite to exist as a command-line tool. The version is not hardcoded here but read from .nvmrc, a one-line file containing 22.12.0 — so the server uses the same version as the development machine rather than whatever the operating system ships.
npm ci installs the libraries. It installs strictly from package-lock.json, a file recording the exact resolved version of every dependency and every dependency of a dependency. This is what makes the install reproducible, and it is a different command from npm install, which is permitted to resolve newer versions. It also fails deliberately if the lockfile and package.json disagree.
Both steps are placed before the Hugo step, so that static/rideshare-sim/ exists by the time Hugo looks for files to copy.
What is stored, and what is generated
Committed to the repository: the source code, package-lock.json, .nvmrc, and the workflow. Not committed: static/rideshare-sim/, the build output, along with node_modules and dist. The distinction is the usual one — a repository holds what a build consumes, not what it produces, since generated files can always be regenerated and a stale committed copy is worse than none.
The practical consequence is that publishing a change requires only a commit and a push; the site rebuilds itself. The cost is two small ones. Because the build begins with a type check, a type error in the simulation halts the whole site’s deployment, not just the simulation’s. And because the output is not committed, a freshly cloned copy has no simulation in it until something builds one.