Rebuilding Turunan Downhill from scratch

A single tall block on the left breaking apart into a neat grid of smaller blocks on the right.

Turunan is a downhill mountain bike racing game that runs in the browser. Ten riders on the grid, six tracks inspired by the Indonesian landscape, about three kilometres of trail per race. You open a link and you are on the start ramp, on a phone or a laptop, with no download and no account.

It did not start that way. The first playable version was one HTML file. By its fifth version that file had grown to 155 KB and 2,130 lines, and every new feature made the next one harder to add. So we rebuilt it from scratch.

The new game is made of 43 small modules instead of one file. It adds a sixth track, two new bikes, a career mode, replays, a daily challenge, background music, and an English version alongside Indonesian. And the game itself now ships about 13% less compressed JavaScript than the old file did.

1 → 43one file to 43 modules
−13%compressed game JavaScript
2.1 KBJavaScript on the landing page
120 Hzfixed simulation step

This post covers why we rebuilt it, how the new code is organised, and what we learned. Some things stay in the garage: we will not go into tuning values, how the computer riders choose their lines, or how online play works on the wire.

A brief backstory

The prototype was built the fastest way we knew: one file, with Three.js loaded from a CDN and everything else inline. Markup, styles, physics, rendering, menus, sound, and online play. That was the right call for finding out whether the game was fun. You could open the file, change a number, refresh, and ride.

It kept growing:

Version Size Lines
First playable 63 KB 1,053
Third 98 KB 1,463
Fourth 115 KB 1,678
Fifth 155 KB 2,130

By the fifth version, five tracks, three seasons, six riders, four bikes, and online races all lived in one script and shared one set of global variables. Any part of the game could read or change any other part. A change to how riders land could surface somewhere unexpected, there was no way to test one piece without loading all of it, and the features we wanted next had nowhere clean to go.

The game felt good. The code around it no longer did.

What we kept, and what we threw away

A rebuild is only worth it if players do not lose what they liked. The five original tracks, the riders, and the feel of pedalling, carving, and landing all had to survive. We also kept Three.js on the same version, so the rendering would not shift under us while everything else moved.

Almost everything else was rewritten. The new code is plain JavaScript modules, bundled by Vite, with no UI framework. Menus and the heads-up display are ordinary HTML; the modules only change their text and state.

The new shape

The 43 modules are grouped by job, about 3,750 lines in total:

Folder What lives there
core Small shared helpers and translations
data Tracks, seasons, riders, bikes, and items, as plain data
game The race loop, physics, race flow, camera, career, replays
world Track geometry and everything built around it
entities Rider and bike models, and how they move
render The WebGL renderer, lights, and sky
fx, audio, input, net, ui Particles, sound, controls, online play, menus

Four rules keep it that way.

Data is separate from logic. Adding a track, a bike, or a rider means editing a file in data, not hunting through physics code. Balancing the game is a data change.

Shared state lives in one place. Anything more than one module needs to write, like the race state, the season, or the rider you picked, sits in a single game object. Everything else belongs to exactly one module, and other modules only read it.

Lower layers never import upper layers. The helpers, the data, and the renderer know nothing about the race, the world, or the menus. The import graph points one way, which rules out a whole class of bugs where a module reads a value before it is ready.

Physics works in track space. More on that below.

A clock that does not care about your screen

The old loop moved the game forward by however long the last frame took. On a 60 Hz phone and a 144 Hz monitor, that is two slightly different games.

The new loop runs the simulation in fixed steps, 120 per second, and draws as often as the screen allows. If a frame takes longer, the simulation catches up in fixed steps; if it is quick, the renderer simply draws again. The ride behaves the same on every device, which matters once you race other people, watch a replay, or compare times on the daily leaderboard.

Thinking in track space

A downhill track twists, drops, and doubles back. Doing rider physics directly in 3D world coordinates means every rule has to account for the shape of the hill.

Instead, physics works in two numbers: s, how many metres along the track you are, and d, how many metres to the right of the centre line. Staying on the trail, overtaking, running wide on a corner, and hitting a jump become simple checks on s and d. One function turns them into a point in the 3D world when it is time to draw.

The computer riders, the minimap, the camera, the replays, and item placement all speak the same language, which made each of them simpler to build.

Sound without sound files

Turunan does not download a single audio file. The sound effects are synthesised in the browser, as they were in the prototype. The rebuild added background music that takes its cues from gamelan and is generated as you play, so it costs nothing to download.

Built for phones first

Most people who open a link to a game do it on a phone, so the rebuild treated phones as the main target, not a port.

  • One-thumb control. Slide sideways to steer, pull down to brake, tap for actions. Pedalling, jumping, and tricks happen automatically. A button layout is still there for people who prefer it.
  • A lighter mode. On phones, data-saver connections, or devices with little memory, the game loads 512-pixel ground textures and a lighter sky, about 1.5 MB per race. The full set stays on desktop.
  • Assets on demand. Photo textures and HDR skies, all CC0 from Poly Haven, load only when you pick a track. If one is missing, the track falls back to a procedural look instead of breaking.

Shipping less while doing more

The old file loaded all of Three.js from a CDN and then its own code on top: about 209 KB of compressed JavaScript before you could ride.

The new game bundle is about 183 KB compressed, with Three.js included. Bundling lets Vite drop the parts of the library the game never uses, which more than pays for everything we added.

The website around the game benefits even more. The landing, help, and privacy pages are separate from the game, and the landing page loads about 2.1 KB of JavaScript. The game code only arrives when you press play, and the browser can start fetching it early because the landing page hints that /play is likely next.

Racing with friends

Online races already existed in the prototype, tangled into the same script as everything else. In the rebuild they became their own module, with a separate transport layer underneath. The race code does not know or care how messages travel.

Anyone can open a room and share a four-character code. Up to six people can join, and computer riders fill the rest of the grid up to ten. We will leave the details of that conversation for another day.

What we learned

  • Prototype in one file, then stop. The single file was the right tool for finding the fun. It was the wrong tool once we knew what the game was.
  • Fix the clock early. A fixed simulation step makes replays, online races, and leaderboards fair across devices. It is much harder to add later.
  • Rules beat refactors. Four simple rules about where code lives have done more for us than any clever abstraction.
  • Measure the download, not the source. The new source is bigger than the old file. What players actually download is smaller.

What is next

The rebuild gives us room to grow: more tracks, more seasons, and more ways to race your friends. We will write about them here as they land.

Turunan is free and runs in your browser.

Play Turunan ↗