← All posts

The Making of CrazyTaxi: Sega's Dreamcast Classic Rebuilt in Three.js

CrazyTaxi is a Three.js rebuild of the Sega Dreamcast Crazy Taxi. The car, the traffic, the camera, the customers and the menus all run the game's own SH-4 code in a small interpreter, matched bit for bit with the decomp. Agent! built it in three days and 157 commits. Play it at ct.gokart.games.

CrazyTaxi: a yellow cab driving through the Arcade city in the browser.
The Arcade city in the browser. The city, the cab and the traffic come from the Dreamcast disc. Three.js draws them, and the game's own code drives them.

Play it now: ct.gokart.games. It needs a WebGL browser and plays with a keyboard or an Xbox (or other standard) controller.

CrazyTaxi rebuilds the Sega Dreamcast Crazy Taxi (USA, MK-51035) in Three.js. The first commit went in on October 22. By the night of October 24 it had 157 commits: 93 on the first day, 41 on the second and 23 on the third. Agent! wrote all of it, one tested commit at a time, and the commit messages read like a lab notebook, with addresses, counts and checks in nearly every one.

It isn't a remake built from screenshots. Nearly everything on screen, and most of how it behaves, comes from the disc itself.

Day one: getting the game off the disc

Most of the first day went into extraction. There is no game data in the repository. You rip your own disc, and one command pulls everything out of it:

python3 tools/extract_all.py '<Crazy Taxi (USA).7z or .chd>' --output local-assets

Behind that command are 17 converters in tools/, each committed in the same three steps: the tool, its tests, then a document of what was found. In order, they handle:

  • The disc: extracted and hash-verified from the raw high-density track. extract_chd.py reads CHD v5 images with its own decoders, a FLAC decoder included, and rebuilds the CD sector error correction. Its output matches the .7z rip byte for byte.
  • Textures and sprites: the TEXDC texture banks, plus the sprite banks, whose palettes were recovered from the executable's SH-4 loader code.
  • Models and cities: POLDC models and the BINC city archives, converted to textured glTF.
  • Characters and motions: assembled by following the game's own draw routine, including the key-curve motions and the two-bone IK they need.
  • Traffic and cabs: built from the USA car table and the cab tables, with each traffic colour as a glTF material variant.
  • Collision, course lines, set objects, customers, stops, destination pictures and replays, each checked against the executable's own tables.
  • Audio: ADX music and voice, and the DTPK sound banks, with their playback rates and instrument programs read the way the Dreamcast's sound driver reads them.

A full run writes 4,744 files (255 MB) in about two minutes.

Running the game's own code

The three.js game started that evening with the city, a placeholder cab and a hand-tuned chase camera.

Less than an hour later, commit 02bf19e threw the placeholder physics away. src/sh4.js is a small SH-4 interpreter, the Dreamcast's CPU, and src/dc-car.js uses it to run init_CarMain and exec_CarMain straight from the game's 1ST_READ.BIN. They run over the disc's course points and collision data, loaded at their original Dreamcast addresses. Drawing, sound and rumble are stubbed or captured, and three.js draws whatever the game asked for.

The rest of the game moved into the interpreter one system at a time:

  • Traffic: every car, train, cable car and signal is a game event, run by the game's own trafficControl and nlExecuteEvent. The cab hits the traffic and the traffic hits back.
  • The camera: camUpdate's chase camera and the countdown's swoop around the cab. Three.js simply takes the game's view matrix.
  • Customers: who waits where, the get-in, the ride, and the rating and time bonus at drop-off.
  • Menus: the title screen, the main menu, the options, the records and the driver select. All of them are the game's code drawing sprites, which src/menu-view.js puts on a canvas.

Every one of these is checked against the decomp's own SH-4 simulator. tools/car_trace.py records a whole fare: the countdown, the pick-up, driving, the drop-off. Every frame of the cab, the camera, every traffic car, the game clock and every customer matches bit for bit in both cities.

That check caught a real bug. Two different parts of the game's math disagreed until the interpreter's 64-bit float moves were fixed to store register pairs in the order the SH-4 actually uses. The decomp's simulator gets the same patch in car_trace.py.

Crazy Box

On day two Crazy Box arrived: the game's mini-games, all 16 run by Init_DCmini and execDCmini. Each new piece of the game was found the same way. Find the routine in the executable, run it in the interpreter, and catch what it draws:

  • Crazy Bowling's pins come from ExecSetObject. They fly when you hit them, and the game counts them.
  • The green guide arrow comes from ExecArrow, drawn in view space and placed back into the world.
  • The TRY AGAIN window is the game's own windowTryAgain, menu cursor included.
  • Clears are saved by the game's own CheckRanking, and they unlock the next boxes. The save is the same 0x25B0 bytes the Dreamcast backs up, stored in localStorage.

One find from this work: the routine named carRumble is not just rumble. It runs the Crazy Dash. With it stubbed out, Crazy Jump topped out at 132 m. With it running, a dash at GO reaches 173 m.

60 fps and hi-res textures

Once it was correct, it had to be fast. The city came in as 13,779 separate meshes, and the browser drew 3,000 to 4,500 calls a frame. On an Auto-Pilot branch, src/static-batch.js merges the static world. All 374 city textures go into one atlas, and the meshes are merged per 4,000-unit cell. That brings it down to about 300 draw calls a frame. In the Mac app at 2560 × 1920 that is a steady 60 fps (minimum 58), up from 37 to 47 before.

Press H (or Y on a controller) for the hi-res texture pack. tools/match_texture_pack.py recomputes Flycast's custom-texture hash for every texture in the game, which maps a Flycast texture pack onto 1,793 of the game's 2,090 textures. With the pack on, the batched city draws from atlases four times the size (8192 × 8192). The release ships those textures as KTX2: 51 MB against the pack's 551 MB of JPEG and PNG.

The rest of the polish on day three:

  • TAA: jittered frames blended into a reprojected history (src/taa.js), on top of 4× MSAA, so the far city no longer crawls.
  • No more flicker: overlapping coplanar details, like the building shadows on the pavement, are pulled toward the eye so they stop fighting for depth.
  • Smaller downloads: music and voices as Opus and AAC (about 150 MB down to 37 MB), and Brotli for everything else (103 MB down to 28 MB over the wire).
  • New extras: Xbox controller support, a city minimap with the shortest road route to the destination in green, and a drop-off time bonus in Arcade rules.
  • Desktop apps: a signed and notarized Mac app, plus Windows and Linux launchers, all playing the web build at ct.gokart.games.

How it was checked

None of this was taken on trust. The decomp trace fixtures check the game logic frame by frame. Headless Chrome runs a fare in both cities after each change and counts errors. tools/draw-order-check.mjs drives the cab with an auto-pilot and compares every frame against a depth-correct reference: 66 of 95 samples were wrong before the draw-order fix and 0 of 565 after. tools/mac-probe.sh drives the Mac app unattended and prints fps and draw calls every second.

Try it

Stop beside a customer, inside their circle, to pick them up. Then follow the arrow to the place in the picture and stop there.

CrazyTaxi is an independent fan project and isn't affiliated with or endorsed by Sega. Crazy Taxi is a trademark of Sega. No game data is in the repository.