Actor Simulation: A Marketplace That Runs Itself
You can't test a three-sided marketplace when nobody shows up to play the other two sides. This is the actor-based simulation I built at Grubhub to give pre-production a heartbeat — statistically realistic diners, merchants, and (the hard part) couriers who actually drive — rebuilt in the browser as a complete simulated day over downtown Chicago.
The Empty-Preprod Problem
Every marketplace company hits the same wall: pre-production environments have all the services and none of the traffic. A delivery marketplace only does anything when all three sides show up — diners placing orders, merchants confirming and cooking them, and couriers accepting assignments and driving them across town. No sane test plan asks thousands of humans to role-play dinner rush in a staging environment, so preprod sits silent: integration bugs, race conditions, and load pathologies all wait politely for production to reveal them.
Two of the three sides are easy to fake. A diner is an API call with a cart; a merchant is a confirmation and a timer. The courier is the hard one — a courier is a physical process. They occupy a position that must evolve continuously and plausibly; they interact with the dispatch system as free agents who accept, reject, or simply ignore offers until the acceptance window expires and the assignment is reaped back into auto-dispatch (the "pocket veto"); and every downstream system — dispatch, ETAs, tracking, the courier location service — consumes the movement they generate. Faking a courier means simulating a person with a car, an opinion, and a GPS chip.
So that's what we built: an actor-based simulation that gives every participant in the marketplace a simple behavioral model and lets the real machinery — dispatch, assignment, order state — run against the synthetic crowd. It caught bugs in pre-production that had previously gone undetected until they hit real diners.
The Actors
Diners place orders on a nonhomogeneous Poisson process — independent arrivals whose rate follows the shape every delivery platform knows by heart: a breakfast shoulder, a lunch spike, a dominant dinner peak, a late-night tail. Arrivals are sampled by Lewis–Shedler thinning:
When an order is placed is only a third of the demand model. The other two thirds are which merchant and where it goes, and both are badly non-uniform in the real thing. Volume concentrates: the top decile of merchants takes roughly 90% of the orders, modelled as a shifted Zipf rank law with the exponent solved live against the roster actually loaded:
The shift earns its keep: a pure Zipf (r₀ = 0) can hit 90/10 too, but on 1,550 merchants only by handing the single busiest restaurant 28% of the entire city's volume. Flattening the head over the top ~2% of the roster keeps the aggregate and drops the leader to a believable ~4%. Over a simulated day the realised top-decile share lands at 89–91%.
Diners, meanwhile, are wherever the people are. A 6 KB density grid — 150 m cells, derived offline from the same OSM building footprints the map-matching demo ray-traces against, weighting each footprint by its floor area — biases placement toward the towers and away from the expressway margins. Each order draws a dozen candidate addresses from the delivery annulus around its merchant and picks among them by weighted reservoir sampling, which is proportional-to-weight in a single pass at fixed cost per order; rejection sampling would stall in the empty corners of the map, and a per-merchant distribution would cost more memory than the whole grid. The result: 55% of diners land in the densest quarter of the map, against 31% for uniform placement.
Merchants — the same ~1,550 real Chicago restaurants from OpenStreetMap the other demos use — are not interchangeable, and almost everything downstream of an order is conditioned on which restaurant it came from. Treating them as one merchant repeated 1,550 times removes exactly the variance the marketplace has to cope with.
In production this was not a modelling problem. Merchants entered their own hours into
the platform, menus carried real prices, and there were years of merchant records and
decades of order history to fit against — or simply to replay. Rebuilt on a public map
extract, none of that exists, and the interesting question becomes how much of it can be
recovered from what OpenStreetMap does carry: an amenity tag
(cafe / fast_food / restaurant), a name, cuisine on 79% of the roster, and opening_hours on 38%.
For what kind of business this is, the name is the most useful of them: a name appearing many times across the roster is a chain — fifty-six Starbucks, forty-five Dunkin', ten McDonald's — and chain-ness predicts ticket size, prep speed, kitchen throughput and tipping better than the amenity tag alone. Nothing is hand-labelled; five classes fall out of the extract, and each merchant draws its traits from its class.
Ticket size is hierarchical, two levels of lognormal: the merchant draws a median from its class, and each order draws around that. It gives the property a flat distribution cannot — two orders from the same restaurant look more alike than two orders from the same city — and it means the classes need no special cases at the extremes. The chain distributions are deliberately tight, because the whole point of a chain is that every location is the same; the independents are wide (σ = 0.58), and the tail is the expensive room. Over a simulated day the per-merchant medians land at $14 for cafés, $16 for chains, $35 for independents — and the top of the independent tail past $200.
Hours are the first thing production simply knew and this does not. They matter more than they sound: when a restaurant takes orders is most of what separates a 4am bakery from a late-night taqueria, and without them the day fills with 3am sushi from places that shut at nine.
The 38% of the roster tagged with opening_hours is richer than you would
expect — split lunch and dinner service, 04:00 openings, closings past midnight, days
marked off. The full specification is enormous (public holidays, sunset,
"first Sunday of the month"), so the parser implements the grammar that actually occurs
and reports what it cannot read rather than silently returning "closed" or
"always open". It handles 99.2% of the real values; the five it rejects are genuinely
broken (11:00-2300, 11:30+, a Sun that should be Su).
The other 62% get hot-deck imputation — the survey technique of filling a missing record by copying a real one from the same stratum, here a random merchant of the same cuisine, nudged a few minutes at the edges. Cuisine carries the signal: measured across the merchants that do have hours, donut shops open at 05:00 and coffee shops at 07:00, breakfast places close at 14:30, pizza runs to 01:00 and steakhouses to midnight. Fitting an opening and closing time per class and drawing them independently is the obvious alternative, and it invents combinations nobody keeps — split lunch and dinner service at a donut shop, a place open 05:00 to 01:00. Copying a whole week preserves that structure for free, including the fact that Sunday is different. The imputed medians land on the real ones (coffee shops 07:00 against 06:45, pizza 11:00 against 10:55) and the roster-wide open curve is unchanged: 2% of the city trading at 4am, 84% at noon.
Popularity is then restricted to whoever is open, with cumulative weights precomputed per half-hour bucket. Rejection sampling would be simpler and wrong: at four in the morning it spends every try on closed restaurants and then, on exhaustion, places the order at one anyway. The 90/10 concentration survives the restriction, at 89–91%.
Being open is not the same as being busy. A per-merchant hourly demand curve was a query in production — decades of order history, grouped by merchant and hour — and there is no equivalent here. The closest available proxy is when a cuisine's members choose to trade: the fraction of them open at each hour, normalised to mean 1, becomes a multiplier on popularity that decides who gets the orders at a given hour while the meal curve still decides how many there are.
Eighteen cuisines have enough tagged members to build a curve from, and the curves come out where you would want them: breakfast places at 2.7× their daily average at 8am and 0.4× at 7pm, steakhouses the mirror image at 0.16× and 2.3×, pizza still at 1.6× at ten at night. The effect is not subtle — of everything ordered between six and eleven in the morning, 41% goes to a morning cuisine and 1.7% to an evening one; by dinner that has inverted to 3% and 22%.
It is a proxy and it has a proxy's limitation: an open-fraction curve saturates. Almost every cuisine is fully open from noon to three, so inside that plateau it cannot tell "open" from "busy" — it captures the envelope, not the peak. Survivable only because the peaks come from the meal curve instead.
Prices are the other thing the extract does not carry, and there the substitute is weaker still: a relative price index per cuisine, set by hand. It earns its place because the alternative is worse — without it the entire spread between a $9 donut order and a $95 dinner falls out of a random draw, so the expensive restaurants are expensive for no reason and a steakhouse is as likely to be cheap as dear. With it, steakhouses land at a $62 median, sushi $47, sandwiches $16 and coffee $10, at a rank correlation of 0.96 with the index.
The subtlety is that a new explanation of variance has to replace variance rather than add to it, or every distribution quietly widens and the calibration is gone. So each class is re-centred on what its own cuisine mix implies and its between-merchant sigma reduced by the same amount — per class rather than globally, because cuisine and class are correlated: expensive cuisines are overwhelmingly independent restaurants, already the priciest class, and a global correction treats the two as independent and lets them compound. Done per class the per-merchant ticket distribution is unmoved, p50 $21 and p90 $51 either way. The realised order percentiles do shift, $21 to $23 at the median, and that one is the demand profiles working rather than a leak: expensive cuisines now win the dinner peak, and dinner is most of the volume.
Kitchens have capacity, which is what finally gives the demand concentration something to do. Each merchant has a prep floor — the time an order takes with an empty pass, 10 to 25 minutes depending on class — and a small number of delivery orders it can work at once, on top of the dine-in service the simulation never sees. Past that, the queue is real:
And the diner sees the quote, which makes demand endogenous: a restaurant quoting twenty minutes over its own norm sheds customers it would otherwise have won, and they land on the merchants around it. Popularity proposes; the kitchen disposes. Mechanically it is rejection sampling on top of the static Zipf law — draw a merchant, keep it with probability e−excess/τ, otherwise draw again — which samples from the product of the two distributions at the cost of an extra draw or two, rather than rebuilding 1,550 cumulative weights every time a kitchen's load changes. Tolerance is measured against the merchant's own floor, because a steakhouse quoting 25 minutes is normal and a Chipotle quoting 25 minutes is not.
The effect is properly nonlinear, which is the interesting part. At 400 orders a day nothing binds at all: no kitchen is ever behind, nobody is diverted. At 3,000 the busiest kitchens run 1.2–1.34× their floor, 296 orders are confirmed into a full pass, and 121 diners go somewhere else. The marketplace acquires a second failure mode that has nothing to do with couriers, and it shows up first at exactly the merchants the 90/10 rule made popular.
Tipping is the one number two different actors both have opinions about, and three effects compose into it. The merchant sets the norm — a fast-food chain order is far likelier to arrive with nothing attached than a tasting menu. The diner has habits that persist: generosity is drawn from the diner's address rather than freshly each order, so the household that tips well tips well every time, which is what makes "varies by diner" mean something more than extra noise. And the check size bends the rate down, because tipping is anchored on the absolute number as much as the ratio. The realised spread across a day runs from 9.6% with a 34% zero-tip rate at the chains to 17% with an 8% zero-tip rate at the independents — a difference the couriers can see, and which the dispatcher, deliberately, cannot.
The delivery offer itself is still priced by named rate constants — 30¢ per estimated minute of trip time including the wait at the restaurant, plus 25¢ per mile — so re-pricing the marketplace is a one-line edit.
Traffic runs on a third daily curve, a multiplier on travel time that bottoms out at 0.88× overnight and peaks at 1.47× in the evening rush. Its overlap with the demand curve is not a coincidence — it is the same rush hour — so dinner orders start landing while the road network is at its worst and then ease underneath it. Deadhead, trip times, promises, courier pay and the dispatcher's own drive-time oracle all move together, because all of them are downstream of the one number. The cleanest place to see it is the delivery leg alone — pickup to drop-off, where neither kitchen queues nor dispatch latency can muddy the measurement: a kilometre of delivery that takes about 4½ minutes at 3am takes nearly 7½ at the height of the evening rush.
Auto-dispatch runs on a one-minute cycle and solves the whole board at once. Batching is the point: dispatching an order the instant it is confirmed throws away the two facts that matter most — who else is about to need a courier, and who is about to become free — and hands the nearest courier to whichever order happened to be confirmed first. Holding orders for a cycle costs latency and buys globally better assignments.
Each cycle builds a mixed-integer program. xco assigns order o to courier c; uo holds the order back for a later cycle at a price that escalates the longer it waits, so deferral is a decision the model makes deliberately rather than a failure it falls into:
The cost coefficient is where the routing engine earns its place. Dispatch projects each pairing forward: a courier becomes free now if idle, or at the estimated end of their current task if busy, and starts from where they'll be at that moment — their current position, or their last drop-off. A bounded reverse search from each pickup answers "how long from every courier to this merchant?" in one sweep, the same one-to-many trick the Pathfinder radius query uses. From there the projection is arithmetic, and every term is priced in seconds of badness:
There is a pleasant surprise in this formulation: every column has exactly one +1 in an assignment row and one +1 in a capacity row, which makes the constraint matrix totally unimodular. Its LP relaxation is therefore already integral, so no branch-and-bound is needed — the model solves exactly as a min-cost flow, with courier capacity on the source arcs and the deferral option as a parallel path to every order. A few milliseconds per cycle, provably optimal. The demo verifies that claim the blunt way: on hundreds of small random boards, the flow solution matches an exhaustive search of every possible assignment, every time.
Whether that optimality is worth anything is a separate question, and the demo reports it live rather than assuming: each cycle also scores what greedy nearest-courier would have done on the identical board. The answer is smaller than optimisation enthusiasts would like, and it depends almost entirely on how much contention a cycle accumulates. At a typical volume and a one-minute cadence the optimum beats greedy by a few tenths of a percent — real competition on the board, but rarely scarce enough for the assignment to be hard. Push the demand dial to its ceiling, which is well past anything this extract would really see, and the dinner rush puts forty orders against three hundred couriers in a single cycle: 468 decision variables, twenty milliseconds to a proven optimum, and a gap over greedy of about one percent. Stretch the cycle to four minutes on top of that and it reaches nearly five.
That is the real shape of the trade, and it runs the opposite way from intuition: a longer cycle makes the optimiser look better and the marketplace perform worse. Waiting gives the model more to reason about, but every order pays the wait, and a declined offer pays it twice.
The obvious metric says the opposite, and it is worth knowing why. On-time percentage improves with longer cycles, right up to five minutes — because the promise made to the diner includes half the dispatch cadence, on the reasonable argument that a promise made in ignorance of how long dispatch will sit on the order is just a promise that gets broken. Which means on-time is measured against a goalpost that moves with the setting under test. Against metrics the cadence cannot move — food sitting on the pass, and click-to-door — the trade is clear: going from five-minute cycles to thirty-second ones halves the late pickups (147 to 73 per thousand deliveries) and takes a minute and a half off the mean order, while the optimisation edge collapses from 3.8% to 0.3%. An easy trade to take, and one you would get backwards by reading the headline number.
Which is the honest case for the formulation: not that it beats greedy by a lot today, but that the interesting constraints are the ones greedy cannot express at all — stacking two pickups from the same block into one trip, holding capacity back for a rush that hasn't landed, fairness floors across couriers. Those break total unimodularity and demand a real branch-and-bound search, which is exactly the direction a formulation, unlike a heuristic, can grow in.
An assignment is an offer, not a command, because the courier is not an RPC:
Couriers are people, so each one draws a personality at shift start — a vector of continuous sensitivities, one per property of an offer they might care about, scored by a single logistic. A handful of named archetypes would read better and model worse: it would assert there are exactly five kinds of person, and that every tip watcher is equally allergic to a bad tip. Real fleets are a cloud, not five points.
The traits are drawn independently, which is itself a claim: being fussy about tips tells you nothing about whether you'll drive across town. The archetype names survive as labels, derived from whichever sensitivity came out furthest above the fleet median — click a courier and you get the label and the numbers under it. They are a reasonable description of a fleet; they are only wrong as a generative model of one. Rejection reasons come off the same arithmetic: the reason a courier gives is whichever term in the sum hurt most, not a separate ladder of guesses.
Two details are worth pulling out, because the obvious way to write them is wrong. The first is that nothing is a threshold. Nobody looks at a $2.40 tip and consults a cutoff; they weigh what the job pays against how long it ties them up, and a slightly worse offer is slightly less attractive rather than categorically refused. A hard floor would also put a kink in the response surface that the optimiser downstream can sit exactly on top of, which makes measured effects depend on where the kink landed. So money enters as the log of the offer's effective hourly rate against a personal reservation rate — the quantity couriers actually talk in, with diminishing returns built in — and the tip enters separately as its share of the payout, because a thin tip on a well-paying job reads as a signal about the customer independently of whether the total is acceptable.
The second is that ρ, the home range, is a preference and not a fence. Couriers go outside it constantly; past the radius each extra kilometre costs a little willingness, through a softened hinge with no corner to catch on. The weight scales with how far through the shift they are: at the start of a stint a drop-off that strands you across town is barely worth noticing, and at the end it is the whole decision. Which is why the same courier takes the same offer at 5pm and declines it at 9pm, and why the last hour of a shift is structurally the hardest to cover.
A courier responds to an offer three ways: accept, reject (back to dispatch, with a reason — watch the feed), or nothing at all — the pocket veto — and after the 45-second acceptance window the offer is reaped and returned to the pool, where it waits for the next cycle. A declined offer is expensive under batch dispatch: the order does not simply move down the list, it waits out the rest of the cycle, which is why the model tracks who has already passed on what and prices an aging order's deferral higher every round. A courier may accept while still working — one active task plus one backlog assignment — and the backlog starts the instant the current delivery lands. An accepted assignment turns into physics: the courier drives the actual road graph (the same contracted OSM network, reference speeds, and routing the CRP and map-matching demos use), marks itself arrived, waits if the kitchen is behind, picks up, drives to the diner, and delivers — pinging its position into the courier location service once a second the whole way. Between deliveries, idle movement goes through a pluggable WanderStrategy — sit still, drift home, park near a restaurant cluster, or chase the recent order hotspots — the same interface shape the production simulator used, so behaviors are swappable data, not code.
Supply Is A Roster, Not A Curve
The tempting shortcut is to conjure couriers on demand: whenever a supply curve wants one more body online, invent one, hand it a personality and drop it somewhere random. That makes supply an output of a curve, and it quietly assumes the thing a real operations team spends its entire week on — getting the right number of couriers to log in at the right times — is free.
It is not free, and it is not a curve. A real fleet is a roster: a known population of people, drawn once before the day starts, who live where people live and keep the same traits, the same car and the same name all day. Some have committed to shifts. The rest are ambient supply, logged off, who may or may not appear. The two behave differently enough that most of what makes a bad night bad lives in the gap between them.
Scheduled couriers signed up in advance for a block. They mostly turn up when they said they would — with a no-show rate and a few minutes of lateness, both of which an operator plans around — they mostly stop when the block ends, and a decent night keeps some of them online past it. While they are on the clock they turn down very little, because that is what the commitment is: their acceptance runs around 72% of offers against 44% for everyone else. Dispatch also gives them a small thumb on the scale, and the reason is not sentiment. A scheduled courier is supply the marketplace has already paid to have standing by, and they are likelier to say yes, so routing work to them converts a committed cost into a delivery and is likelier to stick on the first offer. It is another per-pair constant, so the constraint matrix stays totally unimodular and the min-cost flow stays exact.
The schedule itself is built against the forecast, greedily: walk the day in half-hour blocks, and whenever coverage falls below what a target orders-per-courier ratio demands, start another shift there. It produces the artifact a real schedule has — shifts bunching at the leading edge of each rush, because that is when the requirement steps up — and, more importantly, it is a bet placed in advance. An operator rosters Tuesday for a Friday they have to guess at. The difference between the guess and the night is the entire problem.
Which is why the demo's staffing control is target OPC rather than a headcount, and why moving it re-runs the day instead of re-tuning it: you cannot re-roster Friday at six o'clock. What you can do at six o'clock is ask for help. When live OPC crosses a threshold, the marketplace pushes a call-out to off-duty couriers; each answers according to their own responsiveness, and everyone who says yes arrives seven to twenty-five minutes later, because they were at home when the message came.
That lag is the whole lesson, and the numbers are stark. Take a schedule built for a quiet day and land 2,200 orders on it, averaged over five seeds: with call-outs disabled, the day ends at 71.7% on time, peak OPC 3.95, and 39 orders that never arrive. Turn call-outs on at OPC 1.0 and the same demand — order for order, identical — ends at 81.9% on time with peak OPC 2.44 and 0.6 cancellations. Ten points of service, recovered entirely by asking. It is not free: the couriers' effective hourly rate falls from $36 to $22, because the work is split more ways. That trade is the actual decision an operator makes when they hit send, and it is not obvious in either direction.
One Engine, Two Clocks
The simulation is discrete-event at its core: an event heap of order arrivals, confirmations, food-ready timers, offer decisions, reap deadlines, and route arrivals, processed in time order. Driving is where most simulations get expensive, and the trick here is that it costs almost nothing: accepting an assignment computes a route once and schedules a single arrival event; the courier's continuous position is derived on demand by interpolating elapsed time along the route's packed edge speeds. The expensive-looking part — dozens of vehicles moving smoothly at 60 fps — is a rendering concern, not a simulation concern. That separation is what lets the engine simulate a full 24-hour day, roughly 1,100 orders and three million location pings, in about half a second of compute; the demo throttles it to a configurable multiple of wall clock so you can actually watch.
The same separation was the production design's core economy: actors emit exactly the API traffic the real systems consume — orders, state transitions, location pings — on statistically faithful schedules, without any actor being more than a small state machine.
One Number Generates The Day
Every stochastic draw in the marketplace — arrival times, prep clocks, personalities, tips, which merchant, which diner — comes from one seeded generator, so a day is reproducible from a single 32-bit number. That is worth more than it sounds. Without it, "drop the fleet at 6pm and on-time falls twelve points" is a story about one day. With it, the same day can be replayed with exactly one thing changed, which is the difference between an anecdote and a measurement. The demo shows the seed and has a Replay button next to it: move a dial, replay, and the difference you see is the dial rather than the weather.
It also buys the technique that makes the comparisons on this page and the autodispatch write-up worth trusting. Running two policies against the identical demand stream — common random numbers — removes the between-day variance from the comparison entirely, so the difference that survives is the policy. Measuring a half-percent effect against day-to-day noise several times that size otherwise takes a great many more replications than a browser has patience for.
One seed is enough to make a day reproducible and not enough to make two days comparable. With every draw coming off one sequence, calling out four extra couriers consumes four extra numbers and every subsequent draw shifts — including which restaurant the next order comes from. Two runs differing only in a supply setting face different demand, and the effect you measure is mostly weather. So the draws are split into two independent streams, demand and supply, seeded through different constants. Change the roster, the schedule, the notification threshold, courier behaviour: the orders that arrive, where from, for how much and with what tip are identical, order for order. Checked the blunt way — run the same seed at generous and skeleton staffing, and every order's timestamp, merchant, address, ticket and tip matches, while the number of couriers who worked goes from 332 to 149.
The generator is xoshiro128**, seeded through a SplitMix32 expansion so that neighbouring seeds give unrelated streams rather than visibly correlated first draws. One wrinkle deserves mention, because it is the kind of detail that quietly decides whether a model means anything: a diner is not an entity in this simulation — an order picks an address and that is the whole of it — so there is nothing to hang a persistent trait on. Diner generosity is therefore hashed from the address rather than drawn fresh. The household at that address tips about the same way every time it orders, at no storage cost and with no dependence on what order the events happened to fire in, and it still moves when the seed does.
No Such Thing As A #FREELUNCH
The dials cover a smooth space of ordinary days: more demand, thinner staffing, a longer dispatch cycle. Ordinary days are not what anyone remembers. What people remember is the day something specific happened, and those days are interesting because they broke an assumption the system was built on — not because a number got bigger.
So the demo ships a handful of scenarios, and a scenario is allowed to do something the dials cannot: replace the shape of the day, and make the schedule wrong. Courier shifts are rostered in advance against a forecast, so the model keeps two curves — the shape the day actually follows, and the shape (and volume) the schedule was built for. Set them equal and you get a well-run day. The gap between them is where operations lives.
#FREELUNCH is the one worth watching. A free-lunch promo capped near $20 runs from 11am to 6pm; the courier schedule was built the week before against an ordinary Friday, at an ordinary Friday's volume. Nothing else is changed. Against the same seed — the identical baseline demand, the identical fleet, order for order where the two overlap — here is what that costs:
| Ordinary Friday | #FREELUNCH | |
|---|---|---|
| orders | 1,121 | 2,955 |
| scheduled shifts | 206 | 206 |
| on time | 79.5% | 53.5% |
| peak OPC | 2.67 | 4.46 |
| never arrived | 1 | 395 |
| call-outs sent / answered | 57 / 21 | 562 / 119 |
| click-to-door p50 / p90 | 31 / 44 min | 37 / 52 min |
| ticket p10 / p50 / p90 | $9 / $25 / $72 | $17 / $20 / $26 |
| courier earnings | $18.72/h | $28.51/h |
The scheduled-shift row is the whole story: the same 206 shifts, because the schedule could not know. Every other row is downstream of that one number being unchanged. Nearly four hundred orders never arrive at all. The call-out system fires over five hundred notifications and lands a hundred and nineteen couriers, which is both a lot and nowhere near enough, and they arrive fifteen minutes after they were needed.
Two second-order effects are worth pointing at. The ticket distribution does not merely shift down, it collapses: a promo capped at a round number pulls the $9 coffee order up and the $70 dinner order down onto the same spike, which is why the tenth percentile rises from $9 to $17 while the ninetieth falls from $72 to $26. And courier earnings go up, to over $28 an hour, because there is so much work per person — a day that is a disaster by every service measure is the best day of the month to be driving.
The other scenarios isolate one thing each: a correctly-forecast but thinly-staffed Saturday, where everything runs hot with nothing having gone wrong; a dead Tuesday, where service is flawless and couriers earn $11 an hour, which is how a marketplace loses its fleet without a single late delivery; a day with call-outs switched off, so you can turn them on and hit Replay to watch the identical day rescued; and a four-minute dispatch cycle, where the optimiser has never looked better and the marketplace has rarely looked worse.
Which is the point at which this demo quietly wandered into someone else's territory. Generating synthetic market conditions and measuring what a dispatch tweak or an objective-function change does to them was the job of a separate tool at the company, called ghost. The two never shared a codebase, which in hindsight they probably should have: an actor simulation that can already produce a plausible day is most of what an offline experiment harness needs, and the rest is a seed, a scenario, and the discipline to change one thing at a time. Rebuilt here without an org chart in the way, the two converge on their own: this demo has scenarios and a Replay button, and stops just short of being the other tool.
What To Watch For
Start the day and let it run. Overnight is sparse — a handful of night-shift couriers, an occasional order. Breakfast stirs; shifts start ahead of noon; lunch spikes; and at dinner the marketplace works hard: watch utilization climb, the dispatch queue form, and on-time percentage sag as the fleet saturates. Keep an eye on OPC — active orders per online courier, the demand:supply ratio a marketplace lives and dies by. Near 1:1 every courier is carrying something; past it, orders are stacking up faster than the fleet can move them. The strip chart accumulates the whole story — demand bars, supply line, busy line — as the day builds. Click any courier to follow one actor through its shift: their trait vector, their home, their route, their 1 Hz ping trail, their earnings. Couriers have names — the 2018 Atlantic hurricane list, with the letters that list skips filled in — which costs nothing and changes how the feed reads entirely: "Florence K. rejected #883 — tip too low" is a sentence about a person, and that is the whole premise of an actor simulation.
Then stress it. Crank demand mid-rush and watch OPC climb past 2:1 — the worst real days looked like this — dragging the failure cascade behind it: late pickups, low-tip orders churning through rejection after rejection, and eventually cancellations. Set the call-out threshold to never first and it just gets worse; set it to 1.0 and watch the recovery lag, because a courier who says yes at 6:40 is not on the road until past seven, and the backlog has to drain after that. Staff it generously and dinner gets boring, which is the operational goal — and the courier hourly rate in the end-of-day summary quietly falls, which is the operational cost.
Then push the demand dial to the top and watch a different failure arrive. Below about a thousand orders a day the kitchens are invisible: nothing is ever behind, nobody is diverted. Past two thousand the busiest restaurants start quoting well over their floor, diners begin routing around them, and the on-time rate sags for a reason that has nothing to do with how many couriers are online. Two independent scarcities, one dashboard — which is the honest version of what the operators were actually watching.
When the clock runs out, the demo stops showing what a dispatcher sees and starts showing what an operator reads the next morning: the day added up. Where the thirty minutes of a median order actually went, decomposed into confirming, cooking, sitting on the pass, and driving. What the fleet earned per hour and how unevenly — the busiest tenth of couriers reliably take about a quarter of the trips. What diners paid, tipped, and how often they walked away from a long quote. Which kitchens ran hot. And the two leaderboards worth arguing about: the couriers who worked hardest, and the merchants who were busiest, with the load factor and late rate that explain each other.
Honest idealizations, and this list is the roadmap: dispatch assigns one order per courier per decision and never stacks two pickups into a single trip, which production did and which is the constraint that would force a real branch-and-bound search; an accepted assignment is never reassigned, however the world changes afterwards; courier pay is a fixed rate card, so there is no surge mechanism to answer a bad OPC with money rather than notifications; diners have no memory, so a late delivery costs nothing tomorrow; the courier trait distributions are sized by hand rather than fitted to observed behaviour, as is the cuisine price index — merchant hours and the demand profiles derived from them are the parts that are real data; the demand profiles capture the envelope of a cuisine's day rather than its peak, for the saturation reason above; couriers appear at home at sign-in rather than commuting in; and the GPS pings feeding the location service are noiseless for now — wiring them through the urban-canyon error model and the HMM map matcher, exactly as production data flows, remains the natural next chapter. The structure — three sides of actors, offer/accept/reject/reap, event-driven driving, a day of statistical rhythm — is the real thing.
Interactive Demo
A full simulated day over downtown Chicago. Start with a scenario — #FREELUNCH is the one that goes wrong — or drive the dials yourself. Speed it up, jump to the rushes, pause, click a courier to see the numbers behind their personality, decide when the marketplace is allowed to call couriers in, and toggle the population-density overlay to see where diners are actually landing. Every day has a seed: change a dial, hit Replay, and you get the same day back with one thing different. Let it run to midnight for the full end-of-day breakdown.
Map data © OpenStreetMap contributors, via the Overpass API. Merchants are real Chicago restaurants and diner placement is weighted by real building footprints; every diner, order, courier, and delivery is synthetic.