RELEASE NOTES
TOKYO NEON is an open-world crime-action game built out of nothing but Vite, TypeScript, Three.js and Rapier. No binary art assets at all — the city, the vehicles and the characters are all generated procedurally at runtime. This page is a development log, rewritten as something readable rather than a raw git log.
01 — Architecture and layer boundaries
Before any implementation, the work started with frozen contracts between layers:
five ports — PhysicsPort, ScenePort, InputPort,
HudPort, AudioPort — and immutable value objects such as
Vec3, Rotation (a quaternion) and Transform. The dependency
direction across domain / application / infrastructure / composition is fixed one way; domain and
application know nothing about Three.js, Rapier or DOM types. Vector and quaternion math is
hand-written rather than pulled from a library — a small error in rotate or
slerp shows up as a gameplay bug that's hard to reproduce.
Rendering implements ScenePort in Three.js. Boxes, spheres and
planes share a single unit geometry scaled per node — building dimensions vary continuously, so an
exact-size cache would barely ever hit. Nodes sharing an instanceKey are batched into
an InstancedMesh, with swap-compaction on removal keeping the instance count matched
to the number of live nodes at all times (measured: 500 buildings plus 150 instanced props, more
than 800 draw calls unbatched, down to 297–309 with instancing).
Physics runs on Rapier. Rigid bodies, a kinematic character controller and a raycast vehicle controller are implemented, and Rapier's own types never leak outside this directory. Handles increase monotonically and are never reused, so a stale handle can never end up pointing at a new body.
02 — The city
CityGenerator builds the city deterministically from a seed. The grid is deliberately
irregular: arterial roads run every 3–4 blocks, and roughly a sixth of minor streets are thinned
out into superblocks. Districts are decided from distance-to-centre plus Perlin noise, and
building heights taper gently from the core outward — not a uniform wall of boxes, but something
that actually reads as a skyline.
Overlap between buildings, and between buildings and roads, is checked with AABB tests — a real constraint on the world's shape, not a "probably fine." Measured across three seeds: roughly 470 buildings, about 2,200 props, generation time 8–56ms, zero overlaps, and the same seed reproduces the same city byte-for-byte.
Building meshes are budgeted to at most 60 child nodes each. Windows are drawn as per-floor bands rather than individually, averaging 22–24 leaf meshes per building across three seeds. Which windows are lit is decided from a hash of the block ID, keeping it a pure, reproducible function while lighting roughly a third of them — every window lit doesn't read as a city at night.
03 — Vehicles and physics
Vehicles use raycast suspension. The chassis collider has zero density with mass properties set
explicitly, and the box inertia tensor is computed by hand — that's what makes
VehicleSpec's centre-of-mass offset actually matter; without it, almost every vehicle
rolls on nearly every corner.
The handbrake carries an isRear flag that locks only the rear wheels. That asymmetry
is what turns a handbrake pull into a spin rather than a stop. It used to infer the rear wheels
from their position, which only worked for a two-axle vehicle, and was replaced.
Per-vehicle tuning turned up a truck with no downward centre-of-mass offset at all, lifting off
during sustained hard turns; dropping it to -0.18 fixed it, verified against the
other six vehicles in the catalogue without changing them. The van got a wider track and stiffer
springs, with barely any measured change at 70km/h — sweeping speed found its real limit instead:
stable at full steering lock up to 40km/h, tips over above 50km/h. That's left as is on purpose. A
2,800kg van, 2.1m tall with a 1.85m track, tipping over at full steering lock is physically
reasonable, and pulling the centre of mass low enough to prevent it would have needed
-0.7 — deeper than any other vehicle in the catalogue, more than half the chassis's
own half-height. A van that rolls if driven carelessly reads as more honest than a van that corners
like a sedan. The measured limits are left as a comment in the profile.
04 — AI and population density
Pedestrian AI walks a graph derived from the sidewalks, scatters at gunfire or a car mounting the kerb, and panic propagates to nearby pedestrians. Traffic AI isn't a rail-mounted slide — it drives the same road graph with the same vehicle physics the player uses.
Streaming pools and reuses entities rather than destroying and recreating them —
PhysicsPort can't reassign a body's owner after creation, so a fresh body would be
needed either way. That pooling produced one real bug: entity IDs survive despawn/respawn, so a
reused car inherited the route cache tied to the ID's previous life, and would drive dozens of
metres off-road toward a destination that no longer applied. Fixed by clearing pedestrian state on
reactivation and resetting a newly-activated ID's route before every tick.
Measured over 9,000 ticks on a generated city: no traffic straying more than 20m from the road graph, pools staying at their cap, and roughly 1ms per tick across all three systems combined (against a 16.67ms budget).
05 — Crime and police
Last to land: punches, collision and fall damage, vehicles that catch fire, what the police witness, and the pursuit that gets built from it. These were deliberately wired in that order — crime is never reacted to before it happens, and dispatch reads a wanted level that witnessing has only just produced. Patrol cars run the same raycast vehicle physics as everything else, not a script following the road graph. Light bars hard-switch between red and blue rather than fading smoothly, which read as "police lights" at a distance far more reliably than a smooth crossfade. Assaults signal a threat to pedestrian AI, and a crowd that's witnessed violence actually scatters from it.
FIELD NOTES — Bugs that only showed up when the game was actually run
Some bugs only surfaced once the game was actually run, past a clean typecheck and 154 passing unit tests. This is the most interesting part, so each one is recorded individually.
setNextKinematicTranslation doesn't take effect until the next world step. Reading
the character transform within the same tick returned the old, pre-move position. Locomotion was
writing that stale position back into its facing calculation, overwriting a move that hadn't been
applied yet, every tick. Fixed by having the adapter track the character's transform
authoritatively itself, instead of reading Rapier's lagging internal state.
Raycast suspension was only ever computed inside updateVehicle, which was only
called for the one vehicle being driven. Parked cars sat directly on their raw collision box,
with wheels frozen at their mount position from before the suspension ever extended — half
buried in the road, half sunk into the body, visibly wheel-less. Fixed by updating idle vehicles
every tick too, with the handbrake held.
Edge-triggered input only clears once per frame, but one frame runs as many fixed ticks as it needs to. A single tap of F could be seen by several ticks sharing the same press state, entering and exiting the car once per tick. Below 60fps, the player ended up back where they started. Fixed so a one-shot input is consumed at most once per frame.
Not a lighting bug. MeshStandardMaterial's metallic surfaces have almost no diffuse
term by design — most of their visible colour comes from reflecting the environment map. But
scene.environment had never been set. Vehicle bodies, built around 0.55 metalness,
sank to black; the ground and character clothing, at near-zero metalness, looked correct — which
is what isolated the cause. A PMREM environment map is now baked with a light tint from the sky
gradient, and regenerated whenever the time of day changes rather than every frame.
A <canvas> is a replaced element — position: fixed; inset: 0
alone doesn't stretch it. The HUD's own div filled the screen correctly; only the canvas stayed
at its default 300×150 size. Fixed with an explicit width/height.
Recoil was only ever written to the player entity's aim angle, and nothing ever read it back — the numbers were moving, but the view wasn't. That's worse than having no recoil at all, since the code looks like it's working from a read-through alone. Fixed by layering recoil as an independent offset on top, instead of overwriting the camera facing that movement direction is derived from.
What works now, and what doesn't
Walking and driving, punching and shooting, collision and fall damage, vehicles catching fire, crime witnessing and wanted levels, and police pursuit all actually work. The procedurally generated city has pedestrians and traffic flowing through it, with streaming keeping density up.
Missions and save/load don't exist yet. Crime and police only landed recently and still need work. The same notes are in the README — worth a read before playing.