Skip to content

Duskline

A racing game that runs in a browser tab: 12 worlds, 14 cars, nothing to install, with the skies, cars and sound generated in code. And how it went from 33 to 85 fps once I measured where the time went.

Case studyUpdated October 13, 20265 min read

Sunset Bay, lap one: 219 mph, tenth of ten, with the minimap, slipstream meter and nitro bar.
Role
Solo project: engine, art direction, sound and tooling (211 commits)
When
July – October 2026
Stack
TypeScript, Three.js, WebGL, Web Audio API, Next.js

The problem

A racing game that feels like a real one, in a browser tab: no install, no account, no download screen.

What I built

Twelve worlds, fourteen individually modelled cars and four modes, built around drifting to charge nitro. It plays on a phone, installs as an app and works offline.

What happened

33 → 85 fps on the racing frame after I measured where the time actually went, with render and bundle-size budgets in CI so it stays there.

  • 33 → 85

    fps on the racing frame

  • 12.5k → 5.6k

    meshes per frame

  • 4

    modes, from time trial to a daily challenge

  • 12

    worlds, 14 cars, 4 modes

01

It started as a road on a canvas

The first playable build was a single-file pseudo-3D road drawn on a 2D canvas: projected road segments, parallax scenery and a hand-drawn HUD. No 3D engine yet, just math and a drivable kart.

From there it was rebuilt on Three.js with a real 3D track (a spline with hills and elevation) and a chase camera, then textured, then given a grid of rivals. Each stage is still visible in the captures I kept along the way.

The first playable build: a pseudo-3D road on a 2D canvas.
Today, in the same browser tab.

02

Six stages to a 3D racer

The montage is the short version. Real 3D with procedural surfaces came first, then photoreal CC0 textures under filmic tone mapping, then a nine-car grid with AI rivals whose personalities (how cautious in corners, which lane, how aggressive, when they boost) are seeded from their names, so a rival drives the same way every race.

The cars went from faceted karts to individually modelled road cars, lofted from cross-sections in code. Drift charges nitro, and that became the mechanic the whole game is built around.

The evolution in six captures, from when the game was still called Fantasy Racer (it became Duskline on 15 September).

03

Twelve places to drive

Each world has its own palette, elevation and landmarks: a coastal causeway at sunset, a snowbound mountain pass, eight lanes of new tarmac across a desert, canyon switchbacks above a grid of city lights. The skies are shaders, the tracks are splines with real elevation, and the engine, tyres, wind and music are synthesised live with the Web Audio API. Only a handful of crash, nitro and impact effects are recordings (free CC0 sounds).

Four modes sit on top: Circuit (nine rivals, a standing grid), Neon Run (an endless wet highway), Grand Prix (three rounds, points) and Space Wars (an anti-gravity tube with live ordnance). There's also a daily challenge (the same world, car and laps for everyone) and a replay of your lap from the results screen.

Step one of four: pick a world. Each preview is rendered from the game itself.

04

Making it feel like a game

Looking like a game and feeling like one turned out to be different jobs. Props stopped being scenery you ghost through: hit a tree and you're shoved clear, your speed scrubs and sparks fly. Ram a rival and it spins out. Going off-track gives you a grace window, then a clean respawn where you left the road.

The AI was retuned into an actual race. The leading rival matches your top speed and the field strings out by personality, rubber-banded so one bad lap doesn't end your race, but capped at your nitro ceiling so nobody rockets away uncatchably. The music lifts with your speed and nitro, and call-outs land for the moments that matter: an overtake, the final lap.

05

It looked finished. It didn't feel smooth.

The obvious suspects were the GPU and the physics. Instead of guessing, I profiled the running engine. The physics cost about 0.05 ms a frame. The whole cost was rendering, and it was bound by draw calls: the scene was about 12,500 separate meshes but only 800k triangles, which a GPU handles easily. Dropping the resolution did nothing, because the cost was per-object overhead on the CPU.

Two fixes did most of it. The sun's shadow pass was drawing thousands of distant objects into a shadow map for shadows you couldn't see; tightening it culled them, and the same shadow map over a smaller area came out sharper. Then each prop's handful of sub-meshes was merged into one mesh per material, in the prop's own space rather than the world's, because the wind shader sways foliage based on height within the tree. A world-space merge would have made every tree flail.

The physics cost about 0.05 ms a frame. The whole cost was rendering.
Forest raceBeforeAfter
Meshes / draw calls12,4745,627
Render time~24–30 ms11.75 ms
Frame rate~33 fps~85 fps

Same forest, the same look.

  • The cars were 65% of the frame's draw calls but 0.5% of its triangles. Merging each wheel internally halved the meshes per car, and I spent the savings on rounder tyres and bodies.
  • The neon city was 21,151 meshes, mostly plain boxes on its towers. Merging static parts cut it to 4,971 meshes and 1,838 draw calls to 843, with triangle counts unchanged.
  • Static art was being revalidated on every visit (30 round trips at about 112 ms each) until it got proper cache headers.

06

One rule for the architecture

Game logic never imports React, and React never touches in-race state. React owns the menus, the engine owns the race, and they meet at a single callback. That kept the per-frame path free of re-renders and made the engine testable on its own.

07

Shipping it, and keeping it honest

Getting it ready for other people was its own list: a crash boundary with a way back to the menu, a WebGL check that tells old devices what they're missing, a tap-for-sound fallback for iOS, an offline service worker with a test in CI, and saved data that's migrated rather than dropped when the format changes. Memory was measured across 29 races in a row with no leak.

Render and bundle-size budgets run in CI, so a change that makes it slower or heavier fails the build instead of quietly shipping. And when a claim in the docs turned out wrong, the fix went in the record. The car picker once said ten cars while the header counted fourteen, because one number was typed and the other was read from the code. Now both are counted.

What I'd carry forward

  1. 1Profile before optimising. The part I'd have rewritten cost 0.05 ms.
  2. 2In WebGL, per-object overhead, not triangle count, is usually what costs you the frame.
  3. 3Numbers typed into copy go stale. Read them from the code.
  4. 4Performance without a budget in CI regresses.

Get in touch

Questions about my work, a role, or a project idea. Send a message here or email me directly.

Srikarmks@gmail.com