Cloning Rocket League
On the night of September 13 I started rebuilding Rocket League so it would run in a browser tab. Twenty-three days later it has DFH Stadium and seventeen more arenas, the game's own menus, ported cars and items, its sounds, and online matches with parties, ranks and tournaments that a few friends and I play on a private beta. This is how it got built: the pipeline, the one rule that shaped all of it, how a game's Unreal data becomes something three.js can draw, and the problems that took the longest.
The rule: never guess
The goal was never "something like Rocket League". It was a clone you couldn't tell apart from the game at a glance: the same arena, the same camera, the same menus and the same sounds. That only works if nothing is approximated.
Early on I broke that. A few effects shipped with values tuned until they looked right: a gain on the boost effects, the crowd's colours, the tone mapping, how much fog to mix in. Side by side with the game they were close, and wrong, and every one of them had to be redone. After that the rule became absolute. Every number comes from the game's own data or from the engine code it runs: package properties and archetypes, the compiled UnrealScript, the Wwise sound banks, the HUD's Flash movies, the compiled shaders, and the native code in the game's executable. When the data doesn't give a value, the answer is to find the data, or to capture that exact thing in the real game. Picking a plausible number is not an option.
That rule shaped everything else. Every design note labels its values as confirmed (read directly), likely (read, with one inferred link) or not recovered. And every feature started from captures of my own copy of the game, at my own settings: the title screen, Free Play, the loading screens, each menu. There was always something exact to compare against.
The reference, and the clone beside it
Day one: a box on a green plane
The first commit went in at ten past midnight. The physics came from RocketSim, an open-source re-creation of Rocket League's physics, compiled to WebAssembly, so the car and ball behaved like the game's from the first frame. Three.js draws everything. Physics runs at the game's 120 ticks a second and the renderer interpolates between ticks, so motion stays smooth at any frame rate. That first build was a blue box on a green plane, with the arena's walls drawn as lines.
By one in the morning DFH Stadium was there, built from the game's own meshes. By two, the real ball, the Octane, a first HUD and sounds. By five, bloom, shadows, floor reflections, the Classic supersonic trail, the goal explosion, and the game's car cam and ball cam ported from their camera code. That was one night. Getting each of those to actually match the game took the next two and a half weeks.
These screenshots are in order. The ones in the middle are not pretty: a sandy pitch, light shafts blown out to white, reflections far too strong, the arena drawn into one corner of the window. Each was a step toward the last one.
From a box to DFH Stadium
The shape of it
Before the details, the layout. The clone is a handful of programs in two groups. The first group runs once per game build, on my machine, and turns the game's files into data. The second is what runs when you play.
Once per game build, offline
- The game's installPackages, the shader cache, Flash movies, sound banks, the executable
- ExtractorsOver sixty Python scripts, with UModel, JPEXS and vgmstream
- Packed dataJSON and binary blobs, WebP textures, files named by hash
When you play
- The pagethree.js, RocketSim as WebAssembly, translated shaders, the menu runtime
- The match hostNode.js on the same physics build, the authority in an online match
- The PsyNet serverRust and SQLite, speaking the game's own protocol
The repository holds code and notes, and nothing from the game. The extracted data, the built page, the collision meshes and the tools are all ignored by git, and the extractors read the copy of Rocket League installed on the machine they run on.
build.py assembles the page. There's no bundler and no module graph. three.js and the physics WebAssembly are embedded as they are, and close to 280 JavaScript files are concatenated, in the order they depend on each other, into one module. Data comes in two kinds. Small data goes into the page as JSON blocks that the loading screen parses before any game code runs. Anything large is written to its own file, named with a hash of its contents, and fetched when something first needs it: an arena's level when that arena is played, a car's data when the car is shown. A rebuilt file gets a new name, so a browser can never pair a new page with an old copy of its data.
def data_file(name, data, suffix):
"""data as dist/data/<name>.<content hash>.<suffix>, written once per content, and its path from the page. A rebuilt file gets a new
name, so a browser never pairs a page with a cached copy of older data."""
path = f'data/{name}.{hashlib.sha256(data).hexdigest()[:16]}.{suffix}'
out = ROOT / 'dist' / path
if not out.exists():
out.parent.mkdir(parents=True, exist_ok=True)
part = out.with_name(out.name + '.part')
part.write_bytes(data)
part.replace(out) # a page being served never finds half a file
return path
window.rlFetchData = async path => {
if (!path) return null;
const response = await fetch(path).catch(error => { throw new Error(`data file ${path}: ${error.message}`); });
if (!response.ok) throw new Error(`data file ${path}: HTTP ${response.status}`);
return response.json();
};
The scale is easy to underestimate. Mannfield's level file is 42.5 MB: 218 placed meshes, 55 material passes drawn by 45 translated shader programs, and 57 textures. How a game package becomes a file like that is what the next few sections are about.
Getting the game's data out
Almost nothing in the clone is drawn or recorded by hand, so most of the work was getting data out of the game in a form a browser can use. A handful of tools did the heavy lifting:
- UModel exports meshes, textures and materials from the game's packages. It needed help first: Rocket League's packages carry a licensee version UModel doesn't expect, so the extractor rewrites one chunk header before UModel reads them. The packages are encrypted, too, which is its own puzzle.
- JPEXS Free Flash Decompiler takes apart the Scaleform movies the HUD and every menu are made of.
- vgmstream decodes the Wwise sound banks.
- The shader caches hold every material's real vertex and pixel shaders as compiled bytecode. Where a material's node graph is gone from the packages, its shader still says exactly what it does.
- The executable. For anything only native code knows, like how the camera smooths, how a ball impact picks its sound or when the car rolls itself upright, I read the binary statically: a class dump gives the names and offsets, and the disassembly gives the logic.
Some lessons from that stretch. Most "missing" materials aren't missing; they live in the always-loaded Startup package. The hex cage around the arena isn't a texture: it's a lattice mesh whose bars are the hexagons. UModel drops vertex colours on some meshes, which is why the crowd once bled colours across its seats until every vertex got colour bytes of its own.
Two things went badly. The first was size. The page started as one self-contained HTML file, and once the menu backgrounds went in it weighed 155 MB and every load crawled. The fix was to keep the page small and fetch large data as separate files when it's needed: an arena's level only when that arena is played, a car's data only when it's shown.
The second was a game update in the middle of the project. On September 23 a new build changed the version on every package and recompiled the shader cache, which changed every shader's ID, so every port keyed by ID stopped matching. Everything was extracted again from the updated install, and hand-ported shaders are now matched by their bytecode rather than their IDs.
Straight out of the packages
Reading Unreal packages
Rocket League is an Unreal Engine 3 game, so almost everything lives in packages: .upk files holding classes, meshes, textures, materials, levels and the Flash movies the menus are made of. A package opens with a header and three tables. Names lists every string once, imports lists the objects it borrows from other packages, and exports lists the objects it contains. An export records its class, its parent, the object that owns it, its archetype, and where in the file its data starts.
The packages ship encrypted and compressed. How the extractor gets past that is not something I'm going to write up. What matters here is that it ends up with a plain version 868 package, and a reader for those is about 130 lines of Python:
class Package:
def __init__(self, data):
self.data = data
r = Reader(data)
assert r.u32() == 0x9E2A83C1, 'not an unreal package'
self.version, self.licensee = r.u16(), r.u16()
r.i32(); r.fstring(); r.u32()
name_count, name_off, export_count, export_off, import_count, import_off = (r.i32() for _ in range(6))
self.names = []
r.pos = name_off
for _ in range(name_count):
self.names.append(r.fstring()); r.u64()
# …
self.exports = []
r.pos = export_off
for i in range(export_count):
e = {'index': i + 1, 'class': r.i32(), 'super': r.i32(), 'outer': r.i32()}
e['name'] = self._fname(r)
e['archetype'] = r.i32()
e['flags'] = r.u64()
e['size'] = r.i32()
e['offset'] = r.i64()
r.i32(); r.read(r.i32() * 4); r.read(16); r.i32()
self.exports.append(e)
Objects are stored as tagged properties: a name, a type, a size, then the value. Because every tag carries its size, a reader can skip a type it doesn't understand, which is what makes a small hand-written reader practical. What makes the data useful is the archetype. An export stores only what differs from its archetype, which has an archetype of its own, and so on. A boost pad's mesh, a light's brightness and a car body's chassis spring all come out of that chain, so reading an object means reading everything beneath it first:
def props(self, e, with_archetype=True, component=None):
"""Tagged properties of an export, with archetype values filled in underneath its own."""
base = {}
if with_archetype and e['archetype'] > 0:
base = self.props(self.exports[e['archetype'] - 1], component=component)
r = Reader(self.data, e['offset'])
if (e['flags'] >> 32) & RF_HAS_STACK:
node = r.i32(); r.i32(); r.u32(); r.u16(); r.i32()
if node: r.i32()
r.i32() # NetIndex
if component if component is not None else 'Component' in self.class_name(e): # Goal_TA and friends are components the name does not mark
r.i32() # TemplateOwnerClass
if not self._tag_ahead(r): r.i32(); r.i32() # TemplateName on templates
own, end = self._read_props(r)
base.update(own)
base['__end'] = end
return base
The properties are the easy part. Everything stored after them is harder. A mesh component's baked lighting, for example, is serialized as raw structures that follow its tagged properties, and no tag says how long they are or what shape they have. Those layouts have to be written out by hand, from how the engine itself reads them.
UModel does the heavy lifting for geometry and pictures. Meshes come out as glTF and textures as PNG, and a few lines of Python turn glTF's accessors into plain arrays:
def read_accessor(g, bin_data, idx):
acc = g['accessors'][idx]
view = g['bufferViews'][acc['bufferView']]
fmt, size = GLTF_TYPES[acc['componentType']]
n = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4}[acc['type']]
off = view.get('byteOffset', 0) + acc.get('byteOffset', 0)
return struct.unpack_from(f'<{acc["count"] * n}{fmt}', bin_data, off)
Placements come from the level package itself. The extractor walks the level's actors, StaticMeshActor, the game's own DynamicMeshActor_TA, InterpActor and a few more, and for each placed mesh records its transform, the material of every section and its baked lighting. It all goes into one level.json.
A match's world is also bigger than the level you name. The persistent level lists sub-levels that stream in, some always and others only when a Kismet node, the engine's visual scripting, fires a load at the start of play. scripts/streaming.py follows those links the way the game does:
def levels(pkg, package):
"""The sub-levels a match loads: pkg the persistent level, package(name) a level's decrypted package."""
world = next(e for e in pkg.exports if pkg.class_name(e) == 'WorldInfo' and pkg.obj_name(e['outer']) == 'PersistentLevel')
listed = [(pkg.class_name(pkg.exports[i - 1]), pkg.props(pkg.exports[i - 1])['PackageName']) for i in pkg.props(world).get('StreamingLevels', [])]
kismet = {name.lower(): name for kind, name in listed if kind == 'LevelStreamingKismet'}
loaded = {name for kind, name in listed if kind == 'LevelStreamingAlwaysLoaded'}
todo = [pkg, *(package(name) for name in loaded)]
while todo:
for name in kismet_loads(todo.pop()):
if name.lower() in kismet and kismet[name.lower()] not in loaded:
loaded.add(kismet[name.lower()])
todo.append(package(kismet[name.lower()]))
return [name for _, name in listed if name in loaded]
A map whose world needs something the page can't draw yet is refused, with the reason, instead of shipping looking wrong.
From a mesh to the GPU
Three.js is a scene graph over WebGL. A Mesh pairs a BufferGeometry, typed arrays that become vertex buffers, with a material, which three compiles into a GLSL program. Each frame the renderer walks the graph and issues a draw call for every mesh and material it finds. Most of what follows is about feeding that machinery data it was never designed for.
The packer writes an arena's geometry into three flat binary blobs: float32 for positions and UVs, uint16 for indices, and uint8 for packed normals, vertex colours and lightmap samples. A mesh is then a handful of offsets into them. Loading a level is a base64 decode followed by subarray calls, which are views into the same memory rather than copies:
g = geometries[p.mesh] = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(bin.f32.subarray(m.v, m.v + n * 3), 3));
g.setAttribute('uv', new THREE.BufferAttribute(bin.f32.subarray(m.uv, m.uv + n * 2), 2));
const frames = new THREE.InterleavedBuffer(bin.u8.subarray(m.frames, m.frames + n * 8), 8);
g.setAttribute('tangentZ', new THREE.InterleavedBufferAttribute(frames, 4, 0, true));
g.setAttribute('tangentX', new THREE.InterleavedBufferAttribute(frames, 4, 4, true));
// GNullColorVertexBuffer is white where the mesh stores no colours
g.setAttribute('ueColor', new THREE.BufferAttribute(m.color !== undefined ? bin.u8.subarray(m.color, m.color + n * 4) : new Uint8Array(n * 4).fill(255), 4, true));
g.setIndex(new THREE.BufferAttribute(bin.u16.subarray(m.i, m.i + m.sections.reduce((s, x) => s + x.count, 0)), 1));
m.sections.forEach((s, i) => g.addGroup(s.start, s.count, i));
Two details are worth a line each. The tangent frame, a normal and a tangent, is four normalized bytes apiece, interleaved in one buffer as the game stores them; three uploads the bytes untouched and the shader decodes them from there. And a mesh's sections, the index ranges that use different materials, become geometry groups, so one mesh can carry an array of materials the way a UE3 static mesh carries one per section.
Then there's handedness. Unreal is left-handed with Z up and three is right-handed. The clone keeps Unreal's units and Z up and mirrors Y at one boundary: each placement's matrix gets a flip multiplied in, and the physics state's positions are negated on the way into the scene.
// the scene keeps Unreal's Z up
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const PORTED_MIRROR = new THREE.Matrix4().makeScale(1, -1, 1);
// every placement of a level
mesh.matrix.fromArray(p.matrix).premultiply(PORTED_MIRROR);
// each frame, the interpolated physics state's ball (Unreal's axes) as a position in the scene
ballPos.set(s[0], -s[1], s[2]);
A mirror reverses triangle winding, which would turn every mesh inside out. Three already copes with a transform whose determinant is negative by flipping which face counts as the front, so a mirrored placement costs nothing. The one place that had to know is the code that merges meshes, which bakes transforms into vertices and has to flip the triangles itself.
Materials and shaders
This is where cloning stops being copying. An Unreal material is a graph of nodes, and what draws it is the shaders the engine compiled from that graph. The clone draws materials two ways.
Recipes came first and still draw DFH Stadium. For each material, materials.py follows its instance chain up through its parents, collecting vector, scalar and texture parameters and static switches, and boils it down to a small recipe: blend mode, whether it's two-sided, a colour, which texture is the diffuse and which the normal, an emissive term, how team colours apply. The page turns a recipe into a three.js material. Where a recipe isn't enough, the pitch being the case in point, whose look is a single pixel shader, I patch three's own shader instead of writing a new one. Three builds a material's GLSL from named chunks, and onBeforeCompile hands over the source before it compiles, so replacing one chunk's include line swaps in only the part that differs and keeps three's fog, shadows and colour handling:
mat.onBeforeCompile = sh => {
Object.assign(sh.uniforms, { fieldSkin: { value: skin }, fieldNoise: { value: noise }, fieldLight: { value: light },
fieldTeam: { value: teamColorVectors(r.teamColors) } });
const decl = 'varying vec3 vFieldWorld;';
sh.vertexShader = sh.vertexShader.replace('#include <common>', `#include <common>\n${decl}`).replace('#include <project_vertex>',
'#include <project_vertex>\nvFieldWorld = (modelMatrix * vec4(transformed, 1.0)).xyz * vec3(1.0, -1.0, 1.0); // UE world');
sh.fragmentShader = sh.fragmentShader
.replace('#include <common>', `#include <common>\n${decl}\nuniform sampler2D fieldSkin, fieldNoise, fieldLight; uniform vec3 fieldTeam[2];`)
.replace('#include <map_fragment>', `
// … the pitch's pixel shader, ported
diffuseColor.rgb = clamp(albedo * (grain + 1.25) * 0.75 * lm, 0.0, 1.0);`);
};
That got DFH Stadium close, but a standard material is three's idea of light, not Unreal's, and every approximation is somewhere the clone can drift from the game. So for every other arena the page doesn't build materials from descriptions. It runs the game's own shaders.
Ported shaders. The game's shader cache holds, for each material, the compiled Direct3D 11 vertex and pixel shaders for each way the engine draws it. That's bytecode, but it has a readable disassembly, one instruction per line, and a short list of instructions covers nearly all of it. scripts/dxbcglsl.py translates them one for one into GLSL and keeps the registers as GLSL variables, so each line of output matches a line of the original:
mad r0.xyz, r1.xyzx, cb0[3].xxxx, r2.xyzx
r0.xyz = (r1.xyz * cb0[3].xxx + r2.xyz);
dp3 r1.w, r0.xyzx, cb1[5].xyzx
r1.w = dot(r0.xyz, cb1[5].xyz);
mul_sat r2.xyz, r0.xyzx, r1.wwww
r2.xyz = clamp((r0.xyz * r1.www), 0.0, 1.0);
sample_indexable(texture2d)(float,float,float,float) r3.xyzw, v1.xyxx, t0.xyzw, s0
r3.xyzw = texture(t0, v1.xy).xyzw;
lt r0.x, r0.w, cb0[7].x
r0.x = float(r0.w < cb0[7].x);
discard_nz r0.x
if (r0.x != 0.0) discard;
The rules are as short as the instruction set:
elif base == 'mad':
self.assign(args[0], f'({self.operand(args[1], mask)} * {self.operand(args[2], mask)} + {self.operand(args[3], mask)})', saturate)
elif base in ('dp2', 'dp3', 'dp4'):
width = int(base[2])
value = f'dot({self.operand(args[1], COMPONENTS[:width])}, {self.operand(args[2], COMPONENTS[:width])})'
self.assign(args[0], value if len(mask) == 1 else f'{vec(len(mask))}({value})', saturate)
Which shader a material uses depends on how it's lit: baked into a lightmap texture, baked per vertex, or not baked at all. The packer picks the matching pass from the cache, and the result is a ShaderMaterial per pass. In a ported arena three.js owns the canvas, the camera, the render targets and the draw calls, and the arithmetic that decides a pixel's colour is the game's.
A shader doesn't compute its own inputs, though. Each draw, the engine fills slots in constant buffers: the object's transform, its lightmap scale, the camera, and the material's own values. Many of those are small expressions the engine evaluates on the CPU, such as a sine of time, a fraction or a dot product. The packer records where each slot's value comes from, and the page has a small evaluator for the same expressions:
function ueExpression(e, time, ball = null) {
const x = k => ueExpression(e[k], time, ball), each = (v, f) => v.map(f);
switch (e.op) {
case 'Constant': return e.value.slice(0, 4);
case 'BallPosition': return ball ? [...ball, e.value[3]] : e.value.slice(0, 4);
case 'Scalar': return [e.value, 0, 0, 0];
case 'Time': case 'RealTime': return [time, 0, 0, 0];
case 'Sine': { const v = x('x'); return [e.cosine ? Math.cos(v[0]) : Math.sin(v[0]), 0, 0, 0]; }
// …
}
throw new Error(`uniform expression ${e.op}`);
}
Slots that change while you play, such as the time, the team colours and the ball's position, are refilled every frame. The rest are filled once.
The camera needs the same treatment. The game's shaders work with positions relative to the camera, a projection of its own, and Direct3D's clip-space depth, which runs from 0 to 1 where OpenGL's runs from -1 to 1. The page builds the same constants, and a matrix that converts the depth:
const PORTED_CLIP = new THREE.Matrix4().set(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0.5, 0.5, 0, 0, 0, 1); // GL clip z to D3D's (z + w) / 2
function portedView(U, camera) {
const c = new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld);
U.ueCamera.value.set(c.x, -c.y, c.z);
U.ueViewProjection.value.copy(PORTED_CLIP).multiply(camera.projectionMatrix).multiply(camera.matrixWorldInverse).multiply(PORTED_MIRROR)
.multiply(new THREE.Matrix4().makeTranslation(c.x, -c.y, c.z));
U.ueInvViewProjection.value.copy(U.ueViewProjection.value).invert();
const p = camera.projectionMatrix.elements, m32 = Math.fround(p[14] / 2), m22 = Math.fround((1 - p[10]) / 2);
U.ueMinZMaxZRatio.value.set(m32, m22, Math.fround(1 / m32), Math.fround(Math.fround(m22 / m32) - Math.fround(1e-8)));
}
Textures take care too. They're stored as lossless WebP and decoded with no premultiplication and no colour conversion, so a shader samples exactly what the engine's did. A few formats carry meaning a PNG loses: a two-channel signed format comes out of the exporter as bytes offset by 128, and the shader has to turn that back into signed values.
Baked light and fog
Unreal Engine 3 mostly doesn't light a level at run time. It bakes the light: a lightmap texture per mesh, or colour samples per vertex, which the shader reads. The extractor reads them out of each component's serialized data and the packer ships them beside the geometry.
Things that move, like the cars and the ball, are lit by a light environment. The engine gathers the lights that reach the actor into a small spherical-harmonic function, pulls the brightest direction out as a directional light, and turns what's left into a sky light with an upper and a lower colour. scripts/lightenv.py reproduces that gather, down to which light channels count, so a car under a stadium's lights gets the colours the game gives it.
Fog is the engine's exponential height fog. Its constants are built the way the executable builds them; translucent shaders take them directly, and for opaque materials the same arithmetic is spliced into three's fog chunks.
How a frame gets drawn
Physics runs at 120 ticks a second whatever the frame rate, so the page uses a fixed timestep with an accumulator. Each rendered frame adds the elapsed time, steps the simulation once for every whole tick it owes, and draws a blend of the last two ticks by the fraction left over:
acc += dt;
while (acc >= TICK) {
const c = tickInput(input);
RS._rs_set_controls(me, c.throttle, c.steer, c.pitch, c.yaw, c.roll, c.jump, c.boost, c.handbrake);
prevS.set(currS);
RS._rs_step(1);
currS.set(f32(RS._rs_state(), N));
acc -= TICK;
// …
}
const a = Math.min(1, acc / TICK);
for (let i = 0; i < N; i++) s[i] = prevS[i] + (currS[i] - prevS[i]) * a;
for (let o = BALL_F; o < N; o += CAR_F) for (let k = 18; k < CAR_F; k++) s[o + k] = currS[o + k]; // flags and counters are not blended
The simulation's output is one flat Float32Array: the ball first, then each car at a fixed stride. Positions and rotations blend fine. A flag like "is boosting" doesn't, so everything after the first 18 floats of each car's block is copied from the newest tick instead of mixed.
The renderer is set up to stay out of the way, and the camera's projection is replaced with the game's, which has no far plane:
function makeRenderer(canvas) {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false }); // TASystemSettings.ini MaxMultiSamples=1
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap; // r186: the soft PCF variant
renderer.shadowMap.autoUpdate = false;
renderer.toneMapping = THREE.NoToneMapping; // post.js's final quad applies the level's UE3 tonemapper and scene levels
return renderer;
}
// RocketLeague.exe's perspective builder 0x14069e030 has no far plane: M[2][2] = 0.999, M[2][3] = 1, M[3][2] = -0.999 Near,
// M[3][3] = 0, with Near = GNearClippingPlane 10 (no NearClipPlane line in the Config folders), so DeviceZ = 0.999 (1 - Near/z)
// (cpu/NOTES.md 7). Three's clip space is GL's, NDC z = 2 DeviceZ - 1.
function makeCamera(fov, aspect) {
const camera = new THREE.PerspectiveCamera(fov, aspect, 10);
const perspective = camera.updateProjectionMatrix;
camera.updateProjectionMatrix = function () {
perspective.call(this);
const e = this.projectionMatrix.elements;
e[10] = 1 - 2 * 0.999; e[11] = -1; e[14] = -2 * 0.999 * this.near; e[15] = 0;
this.projectionMatrixInverse.copy(this.projectionMatrix).invert();
};
camera.updateProjectionMatrix();
return camera;
}
Tone mapping is off in three on purpose. The scene is drawn into a half-float HDR render target, and a final full-screen quad does what the game's post-process chain does: the level's tone mapper, one of three variants chosen by a #define, then a small colour lookup table, then desaturation and a colour tint:
#if TONEMAPPER == 0
vec3 c = min(pow(hdr, vec3(0.454545)), 1.0);
#elif TONEMAPPER == 1
vec3 c = clamp(adj2.y * hdr / abs(hdr + adj2.x), 0.0, 1.0);
#else
vec3 f = adj2.y * hdr / abs(hdr + adj2.x);
vec3 g = pow(hdr * adj2.w, vec3(0.454545));
vec3 c = clamp(mix(mix(g, f, clamp((hdr - adj2.z) * 10000.0, 0.0, 1.0)), f, toe), 0.0, 1.0);
#endif
vec3 i = vec3(min(floor(c.rg * 15.0), 14.0), floor(c.b * 14.9999));
vec3 lut = mix(lutNode(i / 16.0), lutNode((i + 1.0) / 16.0), c * 15.0 - i);
Bloom is the one piece I haven't recovered the game's own pass for. The clone has an approximation, and since the game's own settings have bloom off, it's off by default.
Draw calls are the cost three.js can't hide: in a scene like this, per-draw setup is where most of a frame's CPU goes. Two things keep that down. At load, an arena's static meshes that share a material are merged into one mesh per material, with each placement's transform baked into the vertices. Only opaque draws merge, because three sorts translucent ones per object and a merged one would change what blends over what. The key that decides "same" is short:
for (const s of sections) {
const m = mats[s.materialIndex];
if (!m?.visible || m.transparent) continue;
const key = [m.uuid, layout, mesh.layers.mask, mesh.renderOrder, mesh.castShadow, mesh.receiveShadow].join('|');
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push({ mesh, section: s, material: m });
}
And things that repeat, like the boost pads, are InstancedMeshes: one draw for all the copies of a part.
Physics: port it, don't write it
I never wrote car physics. RocketSim already reproduces Rocket League's closely, so the rule became: don't re-implement it, fix it where it differs from the game. Three of those were worth the digging.
RocketSim is C++ on top of Bullet. It's compiled with Emscripten behind a flat C interface: every function takes numbers or returns a pointer into a float array, so JavaScript never holds a C++ object. About fifty functions cross that boundary. Two matter most: one steps the world, and one returns all of it:
EMSCRIPTEN_KEEPALIVE void rs_step(int ticks) {
for (int t = 0; t < ticks; t++) {
rsFreeplayPreTick();
auto held = holdPads();
g_arena->Step(1);
releasePads(held);
rsFreeplayPostTick();
trackFlipResets();
trackBallWorld();
}
}
// Layout: ball[pos3 rot9 vel3 angvel3] = 18 floats, then per car stride 32:
// pos3 rot9 vel3 angvel3 | 18 boost, 19 onGround, 20 supersonic, 21 demoed, 22 boosting, 23 jumping, 24 flipping,
// 25 ballHitTick, 26 worldContact, 27 carBumpCooldown, 28 hasJumped, 29 hasDoubleJumped, 30 handbrakeVal,
// 31 flip resets so far (a counter: compare against the previous frame)
static const int BALL_F = 18, CAR_F = 32;
EMSCRIPTEN_KEEPALIVE float* rs_state() {
// …
}
JavaScript reads that array in place. f32 is (ptr, n) => RS.HEAPF32.subarray(ptr / 4, ptr / 4 + n), a view over WebAssembly's memory with no copy, and the match host and the prediction code read the same layout.
The body looked rigid. In the game, the visible body sits on a spring over the physics body, which is what makes a car lean as it brakes, boosts and slides. The native tick runs that spring on the chassis joint every frame, with values read from the body's own asset: for the Octane, a strength of 200, 50 and 100 on its three axes and a damping of 5. Porting that one function brought the lean back. The same investigation checked spin-outs against the game term by term and found the clone's car spins out as it should.
Porting the spring meant matching the engine's own arithmetic, down to its sine table. The engine converts rotators to matrices through a lookup of 16,384 sines, so the clone builds the same table, with the same float32 rounding, and indexes it the same way:
// FGlobalMath's table (0x1403739d0): sinf((i + i) * PI * (1 / 16384)) for 16384 entries, which a rotator unit indexes as (units >> 2) & 16383
const SPRING_TRIG = Float32Array.from({ length: 16384 }, (_, i) => Math.sin(Math.fround(Math.fround((i + i) * Math.fround(Math.PI)) / 16384)));
const springTrig = units => SPRING_TRIG[(units >> 2) & 16383];
An upside-down car crept away. Dropped on its roof with no input, the car walked 46 units in ten seconds. A trace of its contacts showed why: RocketSim's broadphase dropped every collision pair at the start of each step, so the contact points never survived a tick, and each step added only the deepest corner. The car rocked from corner to corner and friction walked it. The game's physics scene, read from the executable, keeps pairs and their contact points for as long as the shapes overlap. Configuring RocketSim the same way fixed the creep, and made a tick faster.
Flip resets stuck the car to the ball. In the game you touch the ball with all four wheels, get your flip back and leave. In the clone the car clung on and rolled with the ball, because RocketSim's auto-roll counted the ball as ground. The game's vehicle tick, again from the executable, only counts wheels touching world geometry. That's now a small patch applied to RocketSim whenever the WebAssembly is built, documented with the addresses it was read from.
Here is that patch. It's a diff, applied for the WebAssembly build and reverted afterwards, so the RocketSim submodule stays upstream's:
@@ -132,8 +132,13 @@ void Car::_PreTickUpdate(GameMode gameMode, float tickTime, const MutatorConfig&
_UpdateDoubleJumpOrFlip(tickTime, mutatorConfig, jumpPressed, forwardSpeed_UU);
- if (controls.throttle && ((numWheelsInContact > 0 && numWheelsInContact < 4) || _internalState.worldContact.hasContact))
- _UpdateAutoRoll(tickTime, mutatorConfig, numWheelsInContact);
+ // RL (0x140f21a90) counts only wheels on world geometry here, so wheels on the ball never auto-roll the car onto it
+ int numWheelsInWorldContact = 0;
+ for (int i = 0; i < _bulletVehicle.m_wheelInfo.size(); i++)
+ numWheelsInWorldContact += _bulletVehicle.m_wheelInfo[i].m_isInContactWithWorld;
+
+ if (controls.throttle && numWheelsInWorldContact < 4 && (numWheelsInWorldContact > 0 || _internalState.worldContact.hasContact))
+ _UpdateAutoRoll(tickTime, mutatorConfig, numWheelsInWorldContact);
_internalState.worldContact.hasContact = false;
The chassis spring
Making DFH Stadium look right
Getting DFH Stadium from recognisable to the same was the longest single stretch. Some of what it took:
- Materials. The skyscrapers first came out as lit sandstone instead of reflective glass. Shaders were ported from the compiled cache, down to which vertex shader permutation a car's material uses and the way the engine draws two-sided translucency in a single pass.
- Light. The floor's lightmap first painted a large dark patch with hard edges across the pitch, until it was projected over the field skin's own world square. The menu's ball rendered pure black until its dynamic light environment was ported.
- The pitch. The grass is a world-projected field skin with a tiled grain on top, and it took a few passes to stop it blurring at chase-camera distance.
- The image. Tone mapping, bloom and colour come from the level's own post-process chain and the game's tonemapper.
- The small things the eye catches anyway: the ball darkening as it crosses into the goal, the little arrow on the pitch pointing to the ball, the ball's glow when a car hides it.
The bugs along the way became their own gallery. A grey slab cut through a ported car at wheel height. The Halo topper was black. The ball indicator drew over the ball instead of under it, and vanished entirely over the hex floor. The main menu's task bar stayed on screen in Free Play.
Bugs along the way
More arenas
DFH Stadium came first; the other arenas come through a pipeline that packs any map from its packages. Each is its own data file, fetched only when it's played. Several needed something new before they drew correctly: streamed sub-levels the game loads alongside the main one, the right fog out of several attached to a level, each map's post-process chain, bright boost pads with their own archetype, a second directional light on cars, and cube captures for reflections.
Eighteen arenas ship today, among them Mannfield, Farmstead, AquaDome, Forbidden Temple and Champions Field and their variants. A checker renders every standard map from the same camera after each change, which is where the last few screenshots below come from: arenas that draw but haven't shipped yet. Two went backwards. Mannfield (Dusk) and DFH Stadium (10th Anniversary) shipped on their branches, then came out again when a more correct export gave them materials that read scene depth, which the match doesn't provide yet.
Arenas
Cars, decals and toppers
The first Octane was a hand port. One pipeline replaced it that ports any car from its product: the body's skeletal mesh and materials, its decal, its wheels and paint finishes. The Octane, Fennec, Breakout and Merc go through it, with universal decals like Neural Network and Affluenter on every body, painted items in each paint, wheels such as Goldstone, and toppers and antennas like the Halo, the White Hat and the Gold Nugget. Toppers had their own lesson: the Halo rendered black until their materials were translated, and their accent turned out to be applied at full brightness, the way the game's attach code does it.
Effects come from the game's particle systems: the Alpha, Standard and Sparkles boost flames on both exhaust sockets, each drawn from the car's loadout, the jump and dodge effects, the ball's speed trail, and the Classic supersonic trail, which started out thick and magenta until it streaked with its shader's own noise texture. A demolition runs the explosion's particle modules the way the game runs them, at the game's sort priority, and cuts to the victim's camera.
Cars, decals and toppers
Menus from the game's own movies
Rocket League's menus are Scaleform: Flash movies with ActionScript, drawn by the engine. Rebuilding them by hand would never match, so the clone draws the game's own movies: their shapes, text fields in the game's fonts, scale-9 buttons, timelines, colour transforms and filters, laid out where the movies place them.
The pipeline is long but mechanical. Each movie is an export in a package: the extractor writes its bytes out as a .swf, JPEXS dumps that to XML, and gfx_movies.py turns the XML into a tree the page can run. Characters are resolved across the libraries a movie imports. Every placed node keeps its 2×3 matrix, colour transform, scale-9 grid, text field, filters and timeline frames, and every bitmap becomes a lossless WebP. Static colour transforms are baked into the images, while alpha stays on the node, because the movie's tweens animate it.
The runtime is a display list over the DOM, one absolutely positioned element per node, placed by a CSS matrix built from Flash's:
render() {
const s = this.el.style, grid = this.node.grid;
// a grid node lays its art out at the scale's size, so only a negative scale's flip stays on the transform
s.transform = `matrix(${grid ? Math.sign(this.scaleX) || 1 : this.scaleX},${this.b},${this.cc},${grid ? Math.sign(this.scaleY) || 1 : this.scaleY},${this.x},${this.y})`;
s.opacity = this.alpha;
s.visibility = this.visible ? '' : 'hidden';
if (grid && (this.laidX !== this.scaleX || this.laidY !== this.scaleY)) this.layoutGrid();
if (this.art?.pending && this.visible && this.alpha > 0) this.art.pending();
}
Scale-9 art is laid out per node by Flash's own rule: what lies before the grid keeps its size against the near edge, what lies after it keeps its size against the far edge, and the middle stretches. Text fields draw into canvases with the game's fonts, and timelines step at the movie's own frame rate.
The menus are the part with the most small bugs. Buttons showed thin seams at their scale-9 edges. Two menus' rows drew on top of each other. A tab's item count printed over the word COLLECTED. A search bar's animation restarted instead of running on. Every fix came from reading what the movie actually does: which frame a clip stops on, how a timeline's colour transforms stack, which field a label really lives in.
What's there now: the title screen, the new-player flow (a name, then a first car), the main menu over its menu level, PLAY with casual, competitive, tournaments, training and private matches, the garage, a profile with banners, avatar borders, titles and anthems, career stats and match history, and all nine settings tabs. A gamepad drives most of them.
Menus from the game's own movies
Items, drops and the shop
The garage and inventory run on the game's own product catalogue, with the backend holding what each player owns. New items arrive the way they do in the game, through the NEW ITEM popup with its quality colours and EQUIP NOW, whether they come from a match, a redeemed code or the shop. There's an item shop with Credits, a featured page, bundles and esports team shops, and its previews play in the shop's own premium garage, on its turntable.
NEW ITEM and the shop
Sound
Sound is where never guessing was hardest, because a level that's slightly off is easy to talk yourself into.
The worst detour was the engine. I spent a whole session porting the Octane's engine from the game's loop bank and gear model, and it never matched recordings of the real car. It turned out the Octane's default engine doesn't use that system at all. It plays through a granular "REV" model, a Wwise plugin with a sound bank of its own; the loops I'd ported belong to a different engine sound you unlock online. So I ported the REV player itself to JavaScript, running in an AudioWorklet with its EQ and compressor, and checked it against an offline port of the same player before it played in the page. The worklet runs at the game's 48 kHz in 512-frame buffers, as Wwise does, and resamples to the device's rate on the way out; the page's part is to post it the latest engine values each frame. The garage's engine sweep is the reference, because it sounds the same every time.
Levels were the other fight. The boost played about 10 dB too loud against the engine until both were levelled to a recording. Ball hits repeated while dribbling, then went nearly silent, and other players' touches sounded like wall hits. The fix came from the game again: one ball-hit sound per car per physics tick, each sound file's own loudness normalisation applied, and the overall level set from a real kickoff recording, where a hit sits 20.5 dB above the engine and boost. When the ball hits the arena, the game picks a sound by the surface's physical material; every standard arena's collision uses one with no sound of its own, so it falls back to the impact bank's shared layers, and now so does the clone.
A server that speaks PsyNet
The game talks to its online service, PsyNet, through RPCs carried in signed messages. The clone's backend speaks the same protocol, so the page can follow the game's own login and service flows. It's written in Rust with SQLite, and its message types and service tables are generated from the class dump, so its fields carry the game's names.
It grew to cover what the game asks for: accounts and sessions, friends and presence, parties and chat, playlists and private matches, matchmaking and skill, products and inventories, redeem codes, the wallet and the shop, tournaments, and the static data the client syncs at start-up. An admin dashboard manages players, items, titles, bans, codes and the match servers, and a small stats site shows profiles and match histories.
The most interesting bug was names. Sign-up is open, so anyone could take a name that looks like someone else's: "Custom" with a Cyrillic С, a Greek ο, or a zero-width space in the middle. Names are now normalised, with invisible characters dropped, and compared by their Unicode confusable skeletons in two forms, because the confusables data maps a lowercase m to "rn" but leaves the capital alone. Latin can't mix with Cyrillic, Greek or Armenian, and characters that draw as nothing, like the braille blank, are refused. Every signed request also carries an ID the server won't accept twice.
Online play
Online play was the biggest single goal: everything from private matches to ranked. A match runs on a headless host, RocketSim on Node.js, which is the authority. Each browser predicts its own car and the world, and corrects toward the host the way the game does:
- The host keeps an input buffer per player and nudges each player's clock by tiny factors read from the game's binary, from 0.9756 to 1.0256, so the buffer holds the right number of inputs.
- It sends a car's state only when it moves past a dead band, at 60 updates a second, the rate a real match's replay shows.
- When a correction arrives, the page rewinds, replays its inputs and bleeds the error off over the next frames, the way the game's rigid-body actor draws it, so a correction never snaps.
The bleed-off is a few lines. The location error is added to the drawn pose and shrinks by a fraction every frame, at the rates the game's default network settings give:
const NET_SYNC = { LocationFixRate: 4, AngularFixRate: 5 }; // Default__RBActor_TA.NetworkSyncSettings, which the ball and every car share
// each rendered frame, for every body: the location error is added to the drawn pose and bled off
if (loc[3 * k] || loc[3 * k + 1] || loc[3 * k + 2]) {
const f = Math.min(dt * NET_SYNC.LocationFixRate, 1);
let d2 = 0;
for (let i = 0; i < 3; i++) { loc[3 * k + i] -= loc[3 * k + i] * f; s[o + i] += loc[3 * k + i]; d2 += loc[3 * k + i] ** 2; }
if (d2 < 0.1) loc.fill(0, 3 * k, 3 * k + 3);
}
The transport changed halfway. Over a WebSocket, one lost packet stalls everything behind it, which showed up as freezes under packet loss. Match traffic now goes over an unreliable WebRTC data channel. A latency and loss proxy, and test bots that join through the real network client, made all of this testable in two headless pages before anyone played it.
On top of that sits the match itself, rebuilt from the game's data: private matches with team selection and match admin, parties that follow their leader, casual matchmaking with the joining list and the waiting camera, match chat and quick chat, nameplates, the stat feed and its points pop-ups, demolitions and the respawn choice, goal replays and end-of-match highlights, the podium and the winner screen, accolades, connection-quality icons, and ranked reconnects. For practice there are bots: Nexto, an open-source machine-learning bot, ported to JavaScript and checked against its own model on twelve game states.
In a match
Ranked and tournaments
Ranked needed research more than code. Psyonix has said in public how parts of it work; the rest comes from the client's data and the community's own measurements, and every constant on the server has its source written next to it. Players are placed over their first matches, shown in tiers and divisions with the game's rank art, protected from instant demotion, and soft-reset at the end of a season to match measured resets. A search starts within two tiers of your rank and widens to everyone over two minutes.
Ranked
Tournaments came last and are nearly complete: a schedule seeded like the live service's day, registration with a team name and colours, check-in, brackets that page through their rounds, best-of series with their game counts, the pre-match lobby, and cups bought with Tournament Credits.
Tournaments
Checking the work
With this many moving parts, "it looks right" isn't a test. Most features came with a checker: a script that builds a page, drives the real interface in headless Chrome the way a player would, with keys, clicks and a gamepad, and checks what it draws against the game's own data, often against a capture of the real thing. There are about two hundred of them, from the main menu's layout at four aspect ratios to a frame-pacing check and a two-page prediction test through the latency proxy.
Performance got the same treatment. Free Play felt choppy, and measuring frame pacing alongside frame cost found, among other things, a frame-rate cap that halved the frame rate. The scene now merges static meshes by material, draws small boost pads instanced, skips hidden parts of the scene and leaves out multisampling.
The checks didn't prevent every mistake. The worst: on September 23, cleaning up a finished branch deleted half of the shared build folder, because a directory junction inside the branch pointed at it and the cleanup followed the junction instead of removing it. Work on several branches stopped while every extracted asset was regenerated. Since then, junctions are removed and checked explicitly before any cleanup.
The other habit that paid off was writing everything down. Every problem became an issue and every feature a design note with its sources: 298 issues across the two repositories so far, and about 270,000 words of notes.
What's left
Plenty. The rest of the fifty-three standard arenas. Clubs, planned from the game's data but not built. The game's audio bus dynamics, which duck everything else in a loud moment. The surface-specific layer of the ball's arena impacts. The premium garage's lighting. Goal explosions beyond the Classic, and more cars and boosts. Each one is an issue with what's known and what isn't, and each will start the same way: find the data, or take a capture.
By the numbers
- Days
- 23
- Commits
- 1,337
- Issues
- 298
- Arenas shipped
- 18
- Page JavaScript
- 44.7k lines
- Server Rust
- 29k lines
- Checks and extractors
- 224 scripts
- Design notes
- 269k words
Built on RocketSim, three.js, Nexto, UModel, JPEXS Free Flash Decompiler, vgmstream, Emscripten, Rust and SQLite. A personal, non-commercial project, not affiliated with or endorsed by Psyonix or Epic Games. Rocket League and its art, audio and trademarks belong to Psyonix LLC.