▶ Play it live: https://zhameersheraz.github.io/Noctis-GP/
A lunar night grand prix in the browser. Eight maglev open-wheelers, three laps of the 8.88 km Serenitatis circuit, power-ups, and a low sun raking across a procedurally sculpted Mare-style landscape.
Built by zham. Built with Three.js + TypeScript + Vite. No model, texture or audio assets: the cars, the terrain, the sky, the Earth and the entire soundtrack are all generated at runtime.
Every image in this README is a real frame captured from the deployed site on a GPU, not a generated picture. The hero banner is one of those frames with the overlay UI stripped out and a title block laid over it; the body shots are untouched.
| Menu | Race |
|---|---|
![]() |
![]() |
npm install
npm run dev # http://localhost:5173Production build and preview:
npm run build
npm run previewThe build is a plain static site, and vite.config.ts already uses
base: './', so it works from a project subpath with no extra configuration.
git init
git add .
git commit -m "NOCTIS GP - lunar night grand prix"
git branch -M main
git remote add origin https://github.com/zhameersheraz/Noctis-GP.git
git push -u origin mainThen in the repo: Settings → Pages → Build and deployment → Source: GitHub
Actions. This one-time toggle is required: GitHub will not let the Actions
token create a Pages site on a brand new user repository, so the very first
run always reports Get Pages site failed until you flip it. Every push after
that deploys with no further input.
Your game will be live at:
https://zhameersheraz.github.io/Noctis-GP/
First run takes a minute or two while GitHub provisions Pages. Re-running it manually is just Actions → Deploy to GitHub Pages → Run workflow.
| Key | Action |
|---|---|
W / ↑ |
Throttle |
S / ↓ |
Brake / reverse |
A D / ← → |
Steer |
Shift |
Boost (drains the meter) |
Space |
Handbrake |
Q / E |
Use power-up |
R |
Put the car back on the racing line |
Esc |
Pause / back |
On the menus, ↑ ↓ select, Enter confirms, ‹ › adjust, Esc goes back.
| Parameter | Effect |
|---|---|
?race=1 |
Skip the menu and start a race automatically (soak testing) |
?demo=1 |
Let the AI drive your car, including firing your power-ups |
The central design rule is that the rules do not know about rendering. Every number the physics uses lives in a plain class that never touches the DOM, which is what makes the game testable outside a browser.
src/
core/ math, noise, liveries (pure)
sim/ terrain, track, car, ai, items, race (pure rules, no DOM)
render/ world, sky, cameras, dust, carMesh (THREE only)
audio/ RaceAudio (WebAudio only)
ui/ menu + HUD (DOM only)
main.ts bootstrap, input, frame loop
tools/ headless verifiers and the browser playtest
sim/terrain.ts owns one Float32Array of heights. The render mesh is built
from it, the circuit is carved into it, and the vehicle physics samples it
bilinearly. The road you see is literally the surface you drive on.
Raw terrain noise carries several metres of high-frequency energy — far more
than the designed launch ramp — so sampling it directly produces a profile that
throws the car around and completely buries the jump. A real circuit is graded,
so Track.setHeightsFromTerrain does this in four stages:
- sample the terrain,
- subtract the designed ramp, so heavy smoothing cannot eat it,
- smooth hard (three box passes), which removes terrain noise but keeps the crater and the big climbs,
- add exactly one clean ramp back, then erode any remaining unintentional crests that would launch the car.
The launch ramp is anchored to a world position, not an arc length, so editing the control ring can never silently move it off the racing surface.
A single Gaussian ramp sounds right and is not. Measured, it produced 0.2 s of air — a kerb bounce, not a jump. The reason is that airtime is set by how much height the car has to fall back through, and a symmetric bump spends half its amplitude climbing back up, so the ground catches the car on the way.
The shipped ramp is a kicker: two Gaussians joined at the apex, a 36 m approach and a tighter 24 m landing side. Amplitude buys float and a tight approach buys launch, so one parameter buys both. Every car in the field now gets 0.9-1.0 s of air off it, and the headless race confirms it three times a race with nobody leaving the road.
Both halves are plain Gaussians deliberately. An earlier open-ended profile that stayed high for hundreds of metres had to be cut off somewhere, and that cut showed up as a 160% cliff in the road gradient.
Downforce is proportional to speed² and is released over crests. That one
behaviour does two things at once:
- the car sticks to a 56° banking at speed,
- and it genuinely goes ballistic over the kicker, because the centripetal demand at the crest exceeds gravity plus whatever downforce the current speed can generate.
At the racing speeds the AI actually carries (~80 m/s) the demand at the apex is 22.9 m/s² against a release ceiling of 18.7, so the maglev fully lets go rather than only skimming. The verifier asserts that inequality directly, because a ramp that is merely almost steep enough is indistinguishable from no ramp at all.
The AI reasons with the same lateral-acceleration budget the simulation uses, which is why it brakes exactly when the physics says it has to. There is no hidden rubber-banding.
Rather than letting the whole frame bloom (which blows out sunlit bodywork and
regolith), the frame is rendered twice: once with every non-emissive object
swapped for flat black so only bloom-marked materials contribute, and once
normally. The two are summed. Materials opt in by being emissive, which they
signal with toneMapped: false.
If the browser falls back to a CPU rasteriser (SwiftShader, llvmpipe, Mesa software), the game detects it and drops shadows, bloom and pixel ratio rather than crawling at one frame per second.
This project ships with three verification layers. All of them run the real shipped code, not a re-implementation.
npm run typecheck # tsc --noEmit, strict
npm run verify # circuit geometry
npm run verify:race # headless full-race simulation
npm run verify:pages # boots the build from a GitHub Pages style subpath
npm run playtest # real browser, real Chrome (after npm run build)Asserts the circuit is a real circuit: no self-intersection, a sane minimum radius, a drivable maximum gradient, a banking angle inside the buildable range, a launch ramp that is genuinely convex and genuinely launches a fast car, and that the carved terrain at the rail foot meets the road plane (otherwise a gap opens under the barrier wall on deep cuttings).
Drives the real RaceDirector with the real terrain, track, car physics, AI and
power-up field, and asserts that a full three-lap race is completable: every
car takes the flag, nobody goes off, lap times are plausible and consistent,
the field finishes together, the ramp is actually jumped, attract mode is
stable, and restarting reproduces the grid.
Serves the build one directory down and loads it the way GitHub Pages serves a project repo, then asserts the bundle resolves, the stylesheet actually applied, the scene renders and the console stays clean. A relative-path build is easy to assume and easy to get wrong; this checks it.
Launches real Chrome, walks the menu, starts a race, holds throttle, steers, boosts, pauses, picks up and fires a power-up, jumps the ramp, reaches the results board, quits, restarts, resets the car and resizes the window. It also reads the framebuffer back to confirm the scene is actually drawing rather than being a black rectangle, and fails on any console error.
Because headless Chrome has no GPU, gameplay is advanced with
window.NOCTIS.stepSim(), which runs the same input read and the same
RaceDirector.update as the render loop — just without waiting for pixels.
Rendering is verified separately by screenshotting real frames into
playtest/.
window.NOCTIS exposes { world, track, terrain, director, audio, menu, sky, stepSim, snapCamera }
for poking at a running game from the console. window.__fps, window.__errors
and window.__game are also available for automated tests.
None of these were found by reading the code. They are listed because they are the reason the harness exists.
| Defect | Symptom | How it surfaced |
|---|---|---|
| Terrain noise buried the jump | Ramp profile was 16.2 m of noise against a 4.2 m design — the jump did not exist in the geometry | Geometry verifier: vertical curvature came out positive, i.e. a valley |
| Carve blend never reached 1.0 | A 5.7 m gap opened under the barrier wall wherever the circuit cut deep into a crater | Geometry verifier: sampled the carved terrain at the rail foot against the road plane |
dt used out of scope in resolveBarriers |
Rail friction was NaN, so cars never scrubbed speed on contact |
tsc --noEmit |
| Attract pack frozen | The menu had a completely stationary field, because a stray disabled = true was left in the attract spawn |
Browser playtest: "pack leader at 0 km/h" |
| Power-up could never fire | Edge detection tested the function reference, which is permanently truthy, so the key was always "already held" | Browser playtest: item slot never emptied |
| Open-ended ramp profile | 160% gradient — a cliff across the road | Geometry verifier, after the ramp was reshaped |
| Missing favicon | Two 404s in the console | Browser playtest console check |
file:// harness |
Playtest reported a black screen for five minutes; the module had never loaded, because Chrome blocks ES modules over file:// |
Boot probe printing loader=False, NOCTIS=False |
Two more were checks that were wrong rather than the game: a curvature
formula that was off by a factor of 20, and a "trench" metric that was really
measuring the banking. Both are noted in tools/validate.ts, because a verifier
that lies is worse than no verifier.



