Devlog · Zen Ride

The month it went quiet

207 commits between 17 April and 26 July 2026, but only 39 days with any commits at all — and the most important thing that happened to this project happened during the gap. It started as a skating sandbox. It came back as a music game.

17 Apr 2026207 commits · 39 working days26 Jul

About the project


17 – 24 April 2026 · 46 commits

A sandbox, and a look

The first commit is Initial zen skater game (Three.js + TS + Bun) and the fourth is already a live editor — Add LevelEditor for live editing of LevelConfig with Tweakpane integration. Before there is a game there is a way to tune the game while it runs, which turns out to be the only real safety net this project ever builds.

Day two settles the art direction in one line: Ghibli-style rendering pass: painterly grass, colored cel shading, bloom post-FX. It sticks — the config still carries fields called ghibliGrass and ghibliFoliage, and the sky generator's prompt still asks for background art in the manner of Kazuo Oga.

The week ends deep in parallax-occlusion mapping for the road surface, which leaves behind a rule that outlives it: anything that sits on the road — the rider, the sparks, the dust trail, the shadow — has to be lifted by the same extrusion constant, or it sinks into a surface that only looks like it has depth.

25 April – 21 May 2026

Nothing

Two commits on 6 May. Then silence for sixteen days. Whatever happened in that month is not in the log — but what comes out the other side is a different game with a different reason to exist.

22 – 31 May 2026

The pivot

Refactor audio handling and integrate music-based road generation features

That is the whole turn, in one commit message. Within nine days the architecture is rebuilt around it: major refactor into ecs like!, then major folder re-structure and split the audio analyze code, then worker-based analysis, and then the piece that makes it a rhythm game rather than a game with music in it:

feat: implement song time to world distance mapping for accurate player positioning in music mode & added a minimap spectogram!

Tests arrive the same week — Playwright and Vitest, in one commit, at the exact moment the project stops being a toy.

And a revert with a post-mortem attached

The single most useful commit message in the repository is one that undoes a week of work and says exactly why:

Revert “refactor road surface handling to prevent overlaps and improve terrain integration” — The unified heightfield fixed the original overlap and hard-mode terrain problems but introduced FPS regressions and apron artifacts (huge cliffs, road bridging gaps) that we couldn’t tune our way out of. Going back to the per-section apron until we have a better approach.

It names what the change fixed, what it broke, and the condition for trying again. Most reverts say “revert.”

26 May 2026

A debugger built for one frame

Something in music mode was producing a single black frame. One frame, intermittently, in a game that runs at 60 of them a second.

The fix for that is 624 lines of watcher that reads a slab of the framebuffer back off the GPU every frame, inside the animation callback, and pauses the engine the instant it sees one — so the bad frame is still on screen to inspect. The clever part is how it avoids crying wolf: the real bug produces a pure black sample, while a camera whipping under foliage produces a muddy dark colour in the 0.1–0.4 range, so the detector has an absolute-darkness ceiling and rejects anything that is merely dim. It also carries a near-miss threshold that exists purely so a human who saw the flash can find out what numbers occurred when the detector stayed quiet.

The bug it caught:

fix: address dark-flash bug in music mode by clamping ndv calculation to prevent NaN propagation

A single NaN, one frame wide. The watcher is lazy-imported and editor-only, so none of it ships in the game bundle.

1 – 10 June 2026

The game around the game

Scoring, penalties, medals, the end-of-run summary, adaptive quality tiers, mobile, the options screen, a Cloudflare deploy, and major speed refactor.

Two details from this stretch are worth more than the feature list. Penalties scale with song length — the cost of a bump or a stop is defined against a two-minute reference and scaled inversely by how long the track is, clamped, so one mistake stings proportionally the same in a two-minute sprint and a six-minute epic. And a single contact costs a bump or a stop, never both: if a touch turns into a sustained scrape it gets reclassified and only the difference is charged.

The gold economy lands here too, with a decision that removes a whole class of bug:

Gold is derived, never stored as a balance — there's no counter to corrupt or double-spend.

src/game/progression.ts

Your wallet is recomputed by scanning your best medal on every track. Improve a medal and the upgrade pays you the difference automatically; wipe your records and the wallet cleanly zeroes.

14 June 2026

76%, and then it did not matter

The colour system needs to know what a song is. So: a hand-built genre classifier, reading only the features that survive the compressed timeline format, with an offline calibration harness that scores it against the style prompts the songs were written from. The commit message ends with its own report card:

offline calibration/regression harness that scores the classifier against ground-truth genres from docs/songs Style prompts. Current accuracy 76% (electronic/metal/acoustic/dnb all perfect; vocal genres pop/rock/hip_hop fall into electronic — the ceiling of these coarse features). Re-baked all 18 timeline bins + library.json.

It is a good piece of engineering that knows its own limit. The next day, that limit stops being a constraint:

2026-06-15  extracting genre and mood from yamnet model!

A self-hosted 521-tag audio classifier replaces the whole scorecard, and the same day brings orbs emit light now! and we generate level colors from tracks music mood genre and tempo. The hand-built classifier survives as the fallback. Three commits later the log just says nuclear refactor.

15 – 17 June 2026

Fourteen numbers that paint a world

What comes out of the classifier is a genre plus a fourteen-dimension trait vector — hardness, aggression, neon, groove, organic, epic, intimate, urban, uplifting, melancholy, dreamy, retro, tropical, digital. That vector becomes the grass, the canopy, the sky dome, the time of day, the weather and the orb accent.

The insight that makes it produce variety rather than 36 fixed presets is that different genres vary along different axes:

A single shared driver collapses the gamut: this library's metal is uniformly fast (bpm useless) but varies in density; electronic varies in brightness. So the driver is per family.

docs/trait-palette.md

Three drivers do the work — heat, warmth and groove — each a blend of different features, each assigned to the families it actually separates. And the arc is continuous rather than bucketed, which is what stops two metal tracks landing on an identical world.

That document also ships its own bug post-mortem, under a heading that reads “Downstream gotcha (caused a real bug)”: two recolour passes existed, and the second one built a single tint from the grass hue and assigned it to both the ground and the canopy — so for a while every level had trees that exactly matched the field they stood in.

The soundtrack

Thirty-three songs that did not exist

A rhythm game needs a setlist, and a setlist is the one thing an independent project cannot license. The answer here was to write one. Every track in the game is an original, generated to sit in the same lane as a well-known reference — fresh title, fresh lyrics, a style prompt aimed at the same corner of the room. Fifty-five were written; thirty-three shipped.

The house guide for writing them contains the one rule that everything else depends on, and it is phrased as a warning:

Anything in the Lyrics box that is not inside square brackets gets SUNG. That includes bare description lines and parentheses.

And the honest note about the process:

Generate many, then curate — it is a slot machine. Roll the same prompt 4–10 times and keep the take with the best vocal/melody. One-shot output is generic by default; the great version is selected, not summoned.

Raw output was too soft to ship, so there is a three-stage repair pipeline: split the mixdown into stems, re-sum them with per-stem gain and a de-mud EQ — a bell at 300 Hz for the boxiness, a shelf at 9 kHz for the dull top end — then match the loudness, EQ curve and stereo width to a real reference master. The whole chain, including transcode, silence trim, analysis and difficulty baking, is one command.

Two tracks in the list are the same prompt rolled twice and both kept. One song exists in two languages from the same reference. And the thumbnails folder still holds fossils from before any of this — files named after real bands, left over from the prototype that ran on commercial MP3s.

19 June – 26 July 2026

Three ways to ship one game

Character select, 25 riders, mobile gestures, the shop, locked levels, a dedicated allocation-hunting performance pass, analytics, and then the same build going out three doors: Cloudflare for the web, Electron for Steam, and an 831-byte iframe for itch.

The Steam wrapper cost a specific, unguessable discovery:

…appends in-process-gpu and disable-direct-composition — the pair is load-bearing: in-process-gpu alone presents a solid-gray window while offscreen captures render fine.

CLAUDE.md

The desktop build also needs a custom URL scheme rather than loading files directly, because the menu CSS uses root-absolute paths — and that scheme has to be registered with fetch and CORS privileges or every 3D asset loader fails.

There is a cheat, documented in the README: one localStorage key unlocks every song and difficulty. It is gate-only — medals and gold are untouched — and it is compiled out of the Steam build entirely, on the reasoning that paying players should not be able to stumble into a progression bypass.


Two tools that film and analyse the real game

The offline analyser does not decode audio in Node. It boots the dev server on a random port, launches a headless browser, and runs the analysis in the page — because “MP3 decoding in Node is a perpetual dependency mess” and, more importantly, because re-using the exact runtime analyser means a pre-baked timeline cannot drift from a fresh in-browser one. Same WASM, same FFT, same downstream features.

The capture harness works the same way: rather than a mock, it embeds the actual game in a 9:16 frame, deep-links to a track, clicks the game's own play button, pins a watchdog on auto-steer so it plays itself, and waits for the real score card to appear before filming the tail. It records MP4 rather than WebM specifically because WebM from a browser recorder carries no duration header, which makes an early-stopped clip unusable.

What the log admits

Test coverage is deliberately thin… Most ‘verification’ still means running bun dev and observing behavior in the browser; TypeScript + the live editor are the primary safety net.

CLAUDE.md

Which closes the loop on day one. The first real feature was a live editor, and a hundred days later it is still the thing standing between the project and its bugs. The backlog file, meanwhile, contains exactly one item: that scored badges at the end of a track should appear with fireworks.