From MP3 to Track: Building My Babylon.js Racer
I built the racer repo as a browser-first arcade game: a neon runner/racer that can take an MP3, analyze it in the browser, and turn that song into a playable level. The project is intentionally direct. Instead of hiding the runtime behind a large toolchain, the main game lives in a hand-written index.html with Babylon.js, a terrain prototype in terrain.html, and a small pile of audio and image assets. That kept iteration fast while I was still discovering the feel of the game.
The result is a game loop that mixes three systems: procedural road generation, client-side music analysis, and runtime obstacle spawning. The player steers along a curved glowing track, the level geometry is rebuilt from beat data, and hazards like rotors, walls, and chasing wheels are layered on top of the same road frame math.
What I Built
At a feature level, the repo ships a few different ideas in one playable package:
- A Babylon.js road-and-terrain renderer with a strong synthwave visual direction.
- An MP3 ingestion pipeline that runs entirely in the browser.
- Beat-driven level generation that produces ramps, blocks, magnetic paths, and timing markers.
- Hazard systems that stay synchronized with the road instead of being placed in world space and forgotten.
- A terrain prototype page where I could isolate shader and heightfield work without breaking the main game.
That last point mattered more than I expected. terrain.html let me push on procedural terrain, glow shaping, and camera feel in isolation, then port the parts that were worth keeping back into the main game.
Architecture
The first architectural decision was to centralize tuning in one config object. That let me rebalance movement, road shape, terrain scale, and hazard timing without hunting through the file.
const CFG = {
world: { speed: 12, gravity: -36 },
lateral: { maxOffset: 2.8, lerp: 14, mouseScale: 0.015, keyAccel: 10 },
jump: { v0: 14.5, coyoteMs: 120 },
boost: { mult: 1.6, duration: 2.5 },
road: {
halfWidth: 3.2,
length: 2400,
step: 2.0,
waviness: 0.06,
curveAmp: 6.0,
elevAmp: 2.0,
elevFreq: 0.03,
rollAmp: 3.14159,
rollFreq: 0.08
},
path: {
progressive: {
enabled: true,
zGentle: 360,
D_prog: 1500,
p_max: 1.0
}
}
};
From there, the world is built around a road frame. Instead of treating the road as a flat strip, I compute a center point, tangent, right vector, and up vector for a given z, then use that local frame everywhere: road mesh creation, hazard placement, wall orientation, rotor orientation, collectible placement, and camera follow.
function frameAt(z){
const h = 1.0 * CFG.road.step;
const c0 = centerAt(z - h);
const c1 = centerAt(z + h);
const tangent = c1.subtract(c0).normalize();
let right = Vector3.Cross(Vector3.Up(), tangent);
if (right.lengthSquared() < 1e-6) right = new Vector3(1,0,0);
right.normalize();
const tparam = z / CFG.road.step;
const rollBlend = getBlendWeight(z);
const roll = rollBlend * Math.sin(tparam * CFG.road.rollFreq) * CFG.road.rollAmp;
right = Vector3.TransformNormal(right, Matrix.RotationAxis(tangent, roll));
const up = Vector3.Cross(tangent, right).normalize();
const center = centerAt(z);
return { center, tangent, right, up };
}
function buildRoad() {
const N = Math.floor(CFG.road.length / CFG.road.step);
const left = [];
const rightArr = [];
for (let i = 0; i <= N; i++) {
const z = i * CFG.road.step;
const f = frameAt(z);
left.push(f.center.subtract(f.right.scale(CFG.road.halfWidth)));
rightArr.push(f.center.add(f.right.scale(CFG.road.halfWidth)));
}
const road = MeshBuilder.CreateRibbon("road", {
pathArray: [left, rightArr],
sideOrientation: Mesh.DOUBLESIDE
}, scene);
}
That shared frame math is the reason the game feels coherent. The track bends, rolls, and climbs, but hazards still hug the surface correctly because everything is derived from the same local basis.
Music Analysis as Level Design
The second major system is the audio pipeline. I did not want a server-side preprocessing step. The player can upload a file, the browser decodes it, and the game produces a level from the resulting analysis. The implementation is intentionally lightweight: downsample the track, build an energy envelope, estimate tempo with autocorrelation, then derive beat and band-hit events from that signal.
api.analyzeAudio = async function(file){
const ctx = await ensureAudioContext();
const arr = await file.arrayBuffer();
const buf = await ctx.decodeAudioData(arr.slice(0));
const mono = toMonoFloat32(buf);
const ds = downsamplePCM(mono.data, mono.sampleRate, 11025);
const x = ds.data;
const sr = ds.sampleRate;
const env = movingRMS(x, Math.round(0.02 * sr));
const envSmooth = movingRMS(env, Math.round(0.12 * sr));
const flux = diffRectified(envSmooth);
const fluxN = normalize01(flux);
const minBPM = 70;
const maxBPM = 180;
const minLag = Math.round((60 / maxBPM) * sr);
const maxLag = Math.round((60 / minBPM) * sr);
let bestLag = minLag;
let bestVal = -1;
for (let lag = minLag; lag <= maxLag; lag++) {
let s = 0;
for (let i = 0; i < envSmooth.length - lag; i++) s += envSmooth[i] * envSmooth[i + lag];
const val = s / (envSmooth.length - lag);
if (val > bestVal) {
bestVal = val;
bestLag = lag;
}
}
const bpm = 60 * sr / bestLag;
const peaks = peakPick(fluxN, Math.round(0.08 * sr), 0.2);
const t0 = peaks.length ? (peaks[0] / sr) : 0;
const beatDur = 60 / bpm;
const beats = [];
for (let t = t0; t < buf.duration; t += beatDur) beats.push(t);
};
That analysis does two jobs. First, it turns audio into timing data. Second, it lets the game resize itself to fit the song. If the uploaded track is longer than the current road budget, the code extends CFG.road.length and rebuilds the ribbon before play starts. That is the sort of small systems decision that makes a prototype feel less fake.
Runtime Rebuilds and Hazard Systems
The third important piece is the normalized level representation. After analysis, the game builds a compact cube/ramp/path description, stores it in lastCubeConfig, and uses rebuildCubes() as the canonical way to regenerate the course. That means I can rebuild on file upload, on sync nudge changes, and on restart without duplicating placement logic.
rebuildCubes = function(){
clearCubes();
if (!lastCubeConfig) return;
pathSystem.paths.length = 0;
pathSystem.triggers.length = 0;
const pathsToCreate = new Map();
for (const spec of lastCubeConfig){
if (spec.type === 'ramp') {
drawRamp(spec);
continue;
}
if (spec.type === 'extended') drawcubeExtended(spec);
else drawCubeSingle(spec);
if (spec.metadata?.pathId && spec.color === '#ff8a00') {
const pathId = spec.metadata.pathId;
if (!pathsToCreate.has(pathId)) pathsToCreate.set(pathId, {});
const pathData = pathsToCreate.get(pathId);
if (spec.metadata.isStart) pathData.startBlock = spec;
else if (spec.metadata.isEnd) pathData.endBlock = spec;
}
}
for (const [pathId, pathData] of pathsToCreate) {
if (pathData.startBlock && pathData.endBlock) {
const path = new MagneticPath({ id: pathId, startZ: pathData.startBlock.z, endZ: pathData.endBlock.z });
const trigger = new PathTrigger(path, pathData.startBlock, pathData.endBlock);
pathSystem.paths.push(path);
pathSystem.triggers.push(trigger);
}
}
};
I used the same philosophy for hazards. Each hazard family lives in its own isolated initializer and computes its own spawn logic from shared physics and road values. The red wall system is a good example. Instead of hardcoding a wall height, it derives the maximum fair height from jump velocity and gravity, then clamps that against a configured cap.
const maxJumpApex = (CFG.jump.v0 * CFG.jump.v0) / (2 * Math.abs(CFG.world.gravity));
const wallHeight = Math.min(maxJumpApex - wallConfig.clearanceMargin, wallConfig.heightCap);
That one line prevented a lot of bad-feeling deaths. When the player misses a wall, it is because they misplayed the jump, not because the obstacle was authored outside the movement envelope.
Design Notes
The repo is messy in the way a game prototype is messy: lots of handwritten code, a few overlapping systems, and some one-file density that I would split out in a larger production build. But the core architecture has held up well:
- One config object for tuning.
- One road frame model for world placement.
- One analysis pipeline for song-to-level conversion.
- One normalized level format for rebuilds and sync iteration.
If I took this further, I would split the game into modules, formalize the level schema, and separate rendering from authoring/debug UI. But the current structure did exactly what I wanted it to do: let me move fast on game feel, not scaffolding.
check it out here:
https://scriptinghobby.com/racer/
https://www.youtube.com/watch?v=BgTqg9lF7Nw