Startup City
A 3D taxi game that runs in the browser. A whole city is simulated every frame: 592 buildings, 458 vehicles, 724 people, lights, buses, an ambulance and the police. I designed, built and run it alone.
Startup City is a taxi game set in a city I built in October 2026. You drive an orange taxi through 14 rings of streets, pick up a passenger, and get them to an address before the clock runs out. It runs in the browser at city.mnthan.com, on a phone or a laptop, with no download.
The game is the visible part. Under it is a traffic simulation, a crash model, a city generator and a test suite, all written in plain TypeScript, and a renderer that draws the whole city in a handful of draw calls. This case study is about that part. The story of building it, with the drone it started as, is in the post.
Downtown from straight above at dusk. The river bows round the centre; the road across it is Grand Bridge. Development build at commit 132e499, 23 October 2026.
Why it exists
The city is an ad space. Startups can put their name on a video screen, a billboard or a storefront, and taxi drivers see it while they play. I kept that space small on purpose: 6 screens around the centre square, 12 billboards over the avenues and in the river, and 24 storefronts. That is 42 slots, plus 4 sponsored fares that pay double. Slots never grow. An empty screen runs the city channel instead of a “for sale” sign.
A small ad space only works if people come back to play, so the game had to be good first. That decided most of what follows.
Twelve days, 192 commits
The work started on 4 October from a drone delivery prototype I had built a few days before. That evening the drone was gone and the taxi was in. The last commit in this round was on 15 October.
Commits per day, from git log. 6 and 7 October built the traffic, the river and the crash model; 13 October was one-way streets, roundabouts, weather and events.
I worked from a written plan in the repo. First “a city, not a grid”, then eight steps for a real city (blocks of different sizes, a diagonal boulevard, car parks by the places people go), then four phases: how the streets work, landmarks and weather, sharing and a leaderboard, and people. Each step was its own commit, pushed when its tests passed.
The engine does not know about React
Everything that decides what happens is in src/game and src/city: the taxi, the traffic, the fares, the crashes, the people and the police. It is about 19,600 lines of TypeScript with its tests, and no React, no three.js and no browser APIs in it.
The split. The renderer reads the engine’s clock, so what is drawn is what is hit.
Each frame the page calls one method, update(dt, input). The time step is capped at 0.05 seconds, so a tab that comes back from the background does not jump the city forward in one step. The taxi moves in sub-steps of at most 0.3 units and checks walls at each one, so it cannot pass through a building at boost speed.
The interface does not re-render every frame. It reads a snapshot of the game ten times a second, and the engine hands out events (a crash, a fare paid, a star gained) that turn into sounds and flashes. The 3D layers read the engine’s clock directly, so a car drawn in a junction is the car the rules see in that junction.
The same engine runs with no screen at all. That is what makes the tests possible, and later it can run on a server to check a submitted score before it goes on a leaderboard.
A city from a seed
The city is generated, but not at random. Plots are handed out ring by ring from the centre, out to ring 14. Downtown (rings 0 to 3) keeps a fine grid of 10-unit plots; further out, plot widths come from a fixed table, and every third street is an avenue. Outside downtown, blocks are three plots to a side with a courtyard in the middle.
Each building’s look comes from a hash of its position (FNV-1a feeding mulberry32), so every visitor sees the same city. The data for all 592 buildings, 2,801 trees, 1,152 lamps and 245 bus stops builds in about 190 ms.
Some things are counted rather than rolled, on purpose. One car in ten is a police patrol, and it is chosen by counting, so adding the police did not change any other car’s dice. That kept every earlier traffic test result the same.
The HQ junction, where the six screens are. Each screen has a parking bay; watch it for six seconds and the game pays you. Development build, 23 October 2026.
Traffic that never locks up
The traffic is a simulation, not an animation. A car drives out of a building’s roller-door gate, keeps its lane, queues behind whatever is in front, turns at junctions on a curve, and eventually turns into another gate. Nothing appears or disappears in the open. Two in five vehicles from industrial and waterfront gates are trucks, one in six elsewhere is a scooter, and every vehicle has a mass: a car 1, a truck 6, a bus 8, a scooter 0.1.
Junctions were most of the work. The light cycle is 15 seconds: 7 of green each way and half a second of all-red between them, long enough for a queue to empty. And green alone is not enough to go.
The five checks a car at the stop line makes every frame. Over 144 measured runs the longest wait went from 38 to 26.1 seconds.
Dark junctions are where a light has been knocked down, and the diagonal boulevard, which has no lights. There, traffic takes turns like an all-way stop. On the ring road there are no lights at all: side roads give way, and 32 junctions have slip roads. The two roundabouts give way to the circle.
The test that holds it together
The most important test runs the whole city for five minutes of game time: 9,000 steps at 1/30 of a second. Every third frame it checks that no two vehicles overlap, using their real rotated footprints. It also fails if any vehicle waits longer than 24 seconds (1.6 light cycles).
The dice are seeded, so a failing run fails the same way every time. That turned traffic bugs from “sometimes the cars look wrong” into a frame number I could replay. The longest wait at one clock offset turned out to be chaotic, anywhere from 14 to 30 seconds, so the test runs at three offsets and allows at most one of them over the limit, none past 36 seconds.
There are versions with a quarter of the lights knocked out, with buses on their lines, and with the taxi parked just past a junction (compared against a run without it). The whole suite is 322 tests in 28 files and passes in about 105 seconds.
Bugs it caught
- Every gate was on the wrong side. All 308 gates had the lane sign inverted, so every car drove in and out across the oncoming lane. The tests never saw it, because they only checked cars that were driving. Once cars in gates were counted, every five-minute run had 82 to 148 overlaps. Flipping the sign, in the world builder and the depot that had copied it, fixed it.
- Two trucks waited three minutes. Each was 0.1 over the edge of a wide junction box, and each counted as holding it, so neither went. A vehicle now holds a box only when it is more than 0.25 over.
- Braking into a held box. Above 4.5 units a second, one frame of travel was more than the stopping margin, so a braking car lost its right to wait and drove 10 units into a box another car held.
- False overlaps on the river. The test squared every footprint to its street. At a slanted river junction a car and a bus 0.05 apart counted as touching, and about 1 run in 15 failed. It now uses a separating-axis test on the rotated rectangles.
- Red lights nobody could see. Traffic was stopping at 152 junctions between roads and alleys that had no light drawn. Removing those reds let 14% more cars through in five minutes.
What a crash costs
A hit is rated by delta-v: the closing speed along the line of impact, times M/(m+M), where m is the taxi’s mass and M is the other thing’s. Buildings, walls and the train have infinite mass. Damage grows with (v² − 25), so anything under 5 units a second is free, and six head-on hits at top speed wreck the taxi. At full boost one hit takes about a third.
A wrecked vehicle burns for two seconds and then blows up. The blast pushes and damages whatever is within 10 units, and a fire damages anything within 4. Street furniture does no damage to the taxi but still stops it. Breakaway poles go down for 20 seconds and are put back only when the taxi is at least 24 units away and cannot see it happen.
People follow the same idea. Under 1.8 units a second nothing happens; under 8 they are knocked down and get up limping; from 8 they are killed. Six seconds later an ambulance leaves the hospital and drives through the traffic to them. Kills seen by a police car add stars; from three stars there are roadblocks, from four a helicopter. The driver loses no coins for hitting someone, only for getting caught: 100 coins a star.
A whole city on a laptop without a GPU
My laptop has a GPU. A lot of the people I want playing do not, or have a weak one. Chrome without a GPU falls back to a software renderer (SwiftShader), and on it the first version ran at 8 frames a second.
Where the frames came back from. Only things that move are instanced.
Instancing is the usual answer for many copies of a thing. Under SwiftShader it costs about 8 µs per instance per frame, and 5,000 trees, lamps and beams took half of each frame. So nothing that stands still is instanced:
- Buildings: every box of every building is in one mesh. Each vertex carries its box’s centre and size, and the shader draws windows, edges and shading from those. One draw call.
- The ground: one plane. Its fragment shader runs the layout maths again to paint roads, crossings, pavements, water and the pools of lamplight.
- Street furniture: trees, lamps and frames are baked into two merged meshes. Cars, buses, the train and boats are the only instances.
- People: one mesh. The vertex shader walks each person along their path from the game clock.
- Clicks: every building and board is clickable, but picking is plain ray maths in TypeScript, not three.js raycasting, because the boards are shaped in their vertex shader.
The city is cut into cells of 3 plots. The 16 cells around the taxi are built before the first frame, then 2 more per frame as they come into view. Page load went from 3.6 to 1.7 seconds.
On top of that, each device gets a tier. A discrete GPU is high, a phone or an integrated GPU is mid, and a software renderer is low. Four cores or fewer, 4 GB of memory or less, or a city that took over 350 ms to build each drop it one tier. The tier sets the texture size, how finely the coast is drawn, how much of the traffic runs (100%, 85% or 60%) and how often the mini-map redraws. A frame-rate watcher then drops the glow pass, the extra pixels of a sharp screen, the draw distance and finally the resolution, and undoes it all if it did not help.
The same river at night. A day lasts 12 minutes, and every layer reads the same light, so the sky, haze, windows and lamps agree. Development build, 23 October 2026.
The round mini-map is not a second 3D view. The city is painted into a 2D picture once, a few rows at a time while the browser is idle, from the same layout functions. Each frame that picture is drawn rotated under a round clip, nose up.
The same city on a phone and a desktop. city.mnthan.com, 23 October 2026.
Where it stands
The game is online at city.mnthan.com and plays well on a laptop and a phone. It is not launched yet:
- No advertiser. No slot is sold, and prices are not set yet.
- No payment. Online payment is not built. A player can ask for a storefront with 50,000 coins, but the wallet lives in the browser, so every request is checked by hand.
- Leaderboard built, switched off. The daily leaderboard, ad counts and request emails are built (Supabase and Resend) and wait for their keys. Only the share page, which turns a finished shift into a link with its own preview card, is live.
- One driver so far. The handling has been tuned by me and checked in tests and screenshots. It needs other people’s hands on it.
What I would keep from this project for any product: put every rule in code that does not know about the screen, seed the dice, and write one test that runs the whole system for a long time and fails on the first thing that should never happen. That one test found more bugs than I did.
The city has to work when nobody is watching it. That is the only way it looks real when somebody is.