2026-10-27 · 18 min · reverse-engineering · physics · privacy · browser-games · threejs · multiplayer
Why read this
Solidtop 85%Reads all 12 modules and re-runs the bike model: the table's top speed is implied by its other columns (within 3.7 mph) and one bike is stuck in fifth.
- Original analysis
- Explained from first principles
Developer tools & infraRuns in a browserProprietaryPractitioner teardown
How this was scored
- Is it new?
- 1 of 3: An incremental tweak
- Can I trust it?
- 2 of 3: Measures key facts from files, code or configs
- Can I run it?
- 1 of 3: API-only, gated or restrictive licence
- Will I understand it?
- 2 of 3: Mechanism from first principles with figures
- Can I act on it?
- 1 of 3: General advice
- Will it last?
- 1 of 3: Relevant for months
- Does it affect many?
- 1 of 3: A specialist community
- Only here?
- 2 of 3: A teardown or measurement few others did
Score 50 of 100, ranked 510 of 621 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored
roadrash.fun is a browser version of Road Rash, the motorcycle racing game where you can also punch the other riders. I played it for a few minutes and it works. How it works is the question.
The answer is less dramatic than for a game compiled to WebAssembly. Nobody ported the original program. The code and its comments make it plain: this is new JavaScript, using Three.js to draw, that reads the original game's data and applies the original's rules. The courses are lists of bytes. The bike physics are mostly integer arithmetic at 60 ticks a second, with comments that cite the address in the original program each rule came from. The artwork, text, music and videos are the original's.
That session is all I know about how it plays. The rest of this comes from the code: I read all twelve JavaScript modules the page loads, 277,956 bytes in 3,311 lines, which is the whole import graph, and I re-ran the bike model outside the browser on a CPU, using only the numbers in the site's bike table. Everything below is labelled measured (I computed it from the files), reported (the code's own comments or a news report, not re-run) or reasoned (my arithmetic or inference on the other two).
The rights, first
Road Rash is Electronic Arts' game. The page credits the Windows version to Electronic Arts, "developed with Papyrus Design Group, based on Road Rash for 3DO", and reproduces the original credits. It calls itself a fan-made adaptation by olucurious, and says original artwork, characters, music and other assets belong to their owners. The Windows release was in September 1996 (Wikipedia gives 27 September for North America, reported: Road Rash (1994 video game)).
The code says what it serves. The header of app.js lists "original artwork, text (TEXT/ENU.DLL), fonts (the TEXT/FUT files are TrueType Futura Condensed), music and videos". That is the part with legal exposure, and I found nothing that licenses it: no statement from Electronic Arts, no licence on the site, and no press coverage of the project in my searches. The game is not sold on GOG, where a request to bring it there has 4,089 votes (reported: GOG Dreamlist). I did not check every storefront, so I would not say it is sold nowhere.
Unlicensed fan ports of commercial games have been taken down. A browser build of the community-made Vice City engine reVC drew a DMCA notice from Take-Two's brand-protection firm within days of going viral in December 2025 (Generation Amiga), and a native GTA V port for the Nintendo Switch ended after a cease-and-desist (Notebookcheck). I do not know how Electronic Arts treats this one, and nothing here says it has acted. The site may not stay up.
So this is a description of what the page does, from its public code. I did not download its art or audio, there are no screenshots of the game below, and nothing here tells you how to get its data.
What the page is made of
Five layers, read top to bottom in the figure.
The part that is the author's own is the engine code. race.js is 1,941 lines and 169,735 bytes (measured), and it holds the scene, the riders, the fights, the crashes, the pedestrians and the road surfaces. Its first lines import six helper modules: course maps, road paths, road joins, tilt steering, riders' motion and the loading screen. All twelve modules together are 277,956 bytes (measured).
Under it sit the rules. The code cites 53 distinct addresses in the 0x4xxxxx range in its comments, 50 in race.js and six in app.js (measured, by a regular expression, so a few could be coincidence). Each marks a routine or table in the original Windows program that the author read: the course table at 0x46c558, the level lengths at 0x468f58, the tyre grip table at 0x46dce8, the fight damage model around 0x409ae2. These are citations, not code I can check against the original program, so I take them as the author's claim (reported).
And under the rules is the data: course records named RPTH, RNOD, RSEC, RHZD and RROB, sprite frames from HIBOB.DAT numbered up to 322, a text table with 1,364 entries. How the author got them out of the original files is not in anything the page serves. A comment names a tools/bikes.js, which the site does not publish.
A course is a list of byte pairs
The most compact thing in the code is the track. Each step of the road is one pair of bytes, a and b, 4.85 m long. a turns the heading and b climbs. The code integrates the pair like this: a step turns the heading by atan(a / 256) / 2 and climbs b / 256 of its length (reported, the comment above STEP in race.js; the integration loop is in the file and I read it).
Two checks on that, both reasoned. The comment says position advances v >> 8 per 1/256 step at 60 Hz, with speed in mph times 100. At 60 mph that is 5.493 steps a second, so a step is 4.883 m long, within 0.7% of the 4.85 the code uses. And the level lengths in app.js are in tenths of a mile, as the table at 0x468f58 stores them. The shortest race is Napa on level 1, 5.0 miles, about 1,659 steps. The longest is Napa on level 5, 18.5 miles, about 6,139 steps (my division by 4.85 m).
That is why the bend in the figure below is a one-line formula. Hold a constant for 40 steps and the road is a circle arc, with radius 4.85 / (atan(a / 256) / 2). At a = 8 the radius is 310 m. At a = 64 it is 39.6 m. At a = 127, the largest value if a is a signed byte like the slope byte (the code does not state its range), it is 21.1 m (all measured, by evaluating the formula).
The sliders also show the AI's corner speed, because the same figure needs it. Before each bend the AI looks at the tightest one in the next 60 m and caps its speed with 0.9 * sqrt(L * 4.03e7 / a), where L comes from the bike's tyre value as floor(floor(300 * tyre / 256) * 37 / 256). The comment above that line says the formula assumes "lateral grip ~9 m/s²". Evaluated for the seven tyre values in the bike table, the acceleration it implies at the cap is 8.0 to 17.6 m/s² (measured). The comment holds for the two lowest tyre values (8.5 and 9.0 m/s² at a = 64). It does not for the best, 400, where the cap implies 17.3 m/s², 1.8 g, for a rival on a better tyre. Whether that is what the original does, or this port's cap is generous on good tyres, I cannot tell from the code.
A bike is a gearbox and a drag curve
The bike table in data/bikes.json has 15 bikes. Each has a price, a power, a weight, a tyre, a top speed, a gear list with a ratio, an up and a down threshold for each gear, and a throttle ramp. All 15 share the same throttle ramp, 10 ticks in and 15 out.
The straight-line model in race.js is short enough to port in a few lines. This is our port, not their code:
// speed v in mph x 100; 60 ticks a second; flat, dry road, zero steer
const maxT = bike.power * 256 * pScale // pScale = (W+75)/(W+75+(wh-75)/8), about 0.99 for a 180 lb rider
for (let tick = 0; tick < 60 * seconds; tick++) {
const g = bike.gears[gear]
thr = Math.min(maxT, thr + maxT / bike.ramp[0]) // throttle ramps in over 10 ticks
const force = (g.ratio * thr) / 65536
const drag = (130 * v) / 65536 + (v > 0 ? 8 : 0) // 130 v / 65536 + 8, nothing for the slope
v = Math.max(0, v + force - drag)
if (v > g.up * 100 && gear < bike.gears.length - 1) gear++
else if (v < g.down * 100 && gear > 0) gear--
}Force rises with gear ratio and throttle; drag has a constant part and a part that grows with speed; the gearbox shifts at thresholds. The slope is ignored, and the code says so: "no grade term: the original's integrator (0x4390c0) ignores slope, measured in the emulator on City's hilly start". That sentence is the author's measurement (reported), and I did not check it.
I ran it for all 15 bikes, 70 s each, on one CPU thread. The figure shows each bike's run; the thin lines are the other 14.
The cheapest bike takes 5.20 s to go from 0 to 60 mph and 22.53 s to reach 100. The dearest takes 3.17 s to reach 60 mph and 6.35 s to reach 100 (measured, our re-run). Five of the 15 bikes, cheapest first:
| Bike (price) | Power | 0 to 60 mph | 0 to 100 mph | Settles at | Table top |
|---|---|---|---|---|---|
| $2,999 | 35 | 5.20 s | 22.53 s | 103.8 mph | 100.8 |
| $4,495 | 38 | 4.65 s | 13.02 s | 120.2 mph | 121.0 |
| $21,789 | 45 | 3.55 s | 7.85 s | 146.6 mph | 146.2 |
| $29,998 | 42 | 3.75 s | 7.65 s | 156.8 mph | 156.3 |
| $40,000 | 48 | 3.17 s | 6.35 s | 169.9 mph | 161.3 |
The last column is the interesting one. The table carries a top for each bike, and the physics for a bike with a table never reads it. The page uses it for the rev gauge and the engine pitch. The only cap it feeds is a simpler fallback model for bikes without a table (measured: every use of top in race.js and app.js). So I compared it with where the model settles.
It matches. At steady speed in the top gear the model has a closed form:
v_top = ((ratio_last * power * pScale / 256) - 8) * 65536 / 130 / 100 # mph
For all 15 bikes this lands within 3.74 mph of the table's top, with a mean gap of 1.34 mph (measured; seven are within 1 mph). So top is not an input that the physics respects. It is what the other columns say the top gear would do. Every one of the 15 top values is also a whole multiple of 5.041 mph, from 20 to 32 (measured, to within 0.002), which suggests the original stores it as a small integer (reasoned).
One bike does not fit, and the table shows why. The $40,000 bike settles at 169.9 mph, 8.6 above its top of 161.3. Its sixth gear would give 160.6 mph, which is the table's figure. But its fifth gear gives 169.9, faster, and the shift into sixth needs more than 179.2 mph, which fifth gear cannot reach. So in this port the dearest bike runs in fifth for good (measured, our re-run). Either the original program does the same, and the strongest bike never uses its top gear, or the port reads a threshold wrong. I cannot tell which, and the author's emulator check would be the way to find out.
The riders you race
As far as I can see, the rivals ride the same bike model, with a skill factor on the throttle. What differs is a table of AI "brains": ten of them, each with a cruising speed by level, a reach and a set of grudges.
On level 1 the first brain cruises at 87 mph and on level 5 at 175; the others range from 95 to 120 mph on level 1 and 175 to 191 on level 5 (measured, from data/bikes.json). The code adds a rubber band. When the nearest rider within ten steps (48.5 m) is the player, the rival's cap is scaled by a percentage that depends on the gap, from the 20-entry band table:
| Rival vs you | Cap change |
|---|---|
| 5 or more steps behind | +15% |
| 4 steps behind | +14% |
| alongside | +10% |
| 4 steps ahead | +6% |
| 5 steps ahead | +4% |
| 8 steps ahead | 0% |
| 9 or more ahead | −1% |
(measured, read from the table: the index is the gap in steps, truncated, plus 9.) A rival falling behind gets up to 15% more room to speed up, and one pulling away gets none. A cap is a ceiling, not a speed: at the level-5 cruise of 175 mph the far-behind ceiling is about 201 mph (my arithmetic), which no bike in the table reaches without its nitro. Whether a rival gets near its ceiling depends on its bike.
Fights come from the grudges. Each brain keeps a triple of numbers for each of the other riders, which drifts back to its resting value once a second and moves when someone hits it. A rival swings when you are in reach, its grudge against you is high enough and a cooldown of 0.6 s has passed, with a chance per 60 Hz tick of swing / 256 for the level. The five values are 4, 8, 25, 64 and 256 (measured), which works out to these odds while you stay in reach (reasoned):
| Level | Chance per tick | Chance per second | Mean wait |
|---|---|---|---|
| 1 | 1.6% | 61% | 1.07 s |
| 2 | 3.1% | 85% | 0.53 s |
| 3 | 9.8% | 99.8% | 0.17 s |
| 4 | 25% | 100% | 0.07 s |
| 5 | 100% | 100% | one tick |
At level 3 and above, if a rival is in reach and holds a grudge, a swing is essentially certain within a second. Level 1 leaves about a two-in-five chance of a quiet second.
Friends, rooms and the leaderboard
A page with no server could run all of the above. Three features need one, and the server code is not on the page.
Rooms. mano.js opens a WebSocket to /mano on the same host, with ?room=<code> for a friend room or ?pub=<id> for a public one. A room code is four digits, picked at random by the host, and the relay answers "taken" if it is in use; the join box also accepts a six-character code of letters and digits. The first message is hello with the player's name and the analytics id, then join, host or quick. The client pings every 15 s and closes a connection whose pong is still missing 20 s after a ping. If a rider drops, the relay keeps their place for two minutes, and the client keeps a token in sessionStorage for 110 s to claim it back. An invite link is the page URL with ?room=<code>. If the room has already closed when someone follows it, the client reopens it as a private room with them as host. The start is controlled by the host, or by a timer in a public room.
Four digits is 10,000 rooms. Whether the relay slows someone trying codes in turn is not visible from the client, and I did not try.
The leaderboard. Before a race the page asks /api/race for a signed ticket, the server's start time. After a finished race it posts the result to /api/board with the ticket and a trace: the rider's distance along the road in metres, recorded every 2 seconds of race time (measured, the lines in race.js). The comment says the trace is for the leaderboard's check, in functions/api/board.js. So the server can presumably reject a time that does not fit the distance, and the client has a score_rejected event that carries the server's reason, so some are rejected (reasoned). That is a sensible design: a signed start time makes it hard to invent a score after the fact, and the trace gives the server something to check a time against. It would not stop someone who feeds it a plausible trace. I cannot see the checks, so I do not know how tight they are. Points go to boards for the day and week, by city, country and world; the player is an id the browser makes up and stores as rr-id. How the server knows a city is not visible (reasoned: most likely from request metadata).
Saves. There is no account. Progress is one JSON object in localStorage under rr-save, written when it changes and when the page is left. The other keys are rr-id, rr-tilt-sensitivity and rr-install-dismissed.
Phones, install and analytics
On a phone the game is designed for touch. Steering by tilt is optional, asks for permission only on a tap, and projects gravity onto the screen's right edge instead of using one angle: the code's comment says that "using gamma alone jumps near vertical and reverses steering when the phone rotates into landscape". Its three sensitivities are the numbers 28, 22 and 16 (I read them as degrees of tilt). A service worker is registered at /sw.js. The Add to Home Screen offer appears from a player's second race, and a dismissal is remembered for three days. On iPhone, where no install prompt exists, it shows a two-tap hint.
Every course image is loaded before the countdown. The code's comment puts a course at about 10 MB of art, serves each as lossless WebP "about a third smaller", falls back to PNG, and times an image out after 75 seconds (reported; I did not download any).
The page reports to PostHog, and to Cloudflare's web analytics. Only three hostnames count as live (roadrash.fun, roadrash.pages.dev, roadrash.kayode.me); a local run stays silent. From analytics.js: session recording is off, exceptions are captured, and profiles are created only for identified users. I count at least 18 event names (measured, by searching for track( calls), among them race_started, race_ended, score_rejected, install_prompt and time_spent. The last is sent every five minutes and when the page is hidden, split into racing and menu seconds, with a menu left untouched counting for no more than two minutes. When a race fails to start, the first 200 characters of the error message are sent as well. The analytics id is also passed to the room server in the hello message, so room events can be matched to it.
I found no cookie notice, consent control or privacy text on the page or in any module (measured, by searching for those words). I can only say what the code sends, not how the project handles it; PostHog's own settings can change what is stored.
One detail shows the care that went in. The site moved from roadrash.kayode.me and roadrash.pages.dev to roadrash.fun. Browsers keep storage per site, so a player would lose their save. The old pages now redirect to the new one carrying the save, the id, the tilt setting, the install-dismissed flag and the PostHog id, encoded in the URL fragment, which the new page reads, stores and then removes with history.replaceState. A fragment is not sent in a request, so the data never reaches a server on the way (reasoned, from how HTTP works). The query string is kept too, so invite links still work.
What I did not do, and what I do not know
I did not run the game, so nothing here is a frame rate, a load time or a measure of how it plays. The bike numbers are our re-run of the model on a flat road. They are not lap times, and they leave out steering, grip, crashes and the road surface, which the real model includes.
I did not read the course data files, the sprite sheets or any art. Figures about their contents, such as the record names and the frame numbers, come from the code that reads them.
I did not see the server. The room relay, the ticket check, the leaderboard and any limit on room codes are inferred from what the client sends.
I do not know where the data came from beyond the code's own account, what the author's emulator checks found (the comments cite them), whether the original also leaves the $40,000 bike in fifth, or how Electronic Arts regards the site. Those would settle the questions here: a diff of this model against the original program's behaviour, a look at functions/api/board.js, and a statement from the rights holder.
For the other end of this kind of project, a modern engine recompiled whole and served as one binary, see the GTA V browser port. This one is the opposite approach: it keeps none of the program and all of the data.