Behind the <canvas />;

Engineering a 3D journey from scratch

How a motorcycle journey became a road, a renderer, a binary format, an editor, and a language built to tell one story.

Special thanks

Before we go deep into a long technical story, here are the people who made it better. They saw this work while it was still wrong, and they said so.

  • Story review

    Kotchakorn Khwamchareon

    former Co-founder and Director, Cleverse Academy

    She made time to read the first version of this story, and she named the one improvement it needed most. It was a lot of work to make. And years before any of this, she helped me through a hard time as a teaching assistant at Cleverse Academy.

  • Where the story starts

    Thanasit Suwanposri

    former Instructor, Cleverse Academy

    They made time to remember their years with me at Cleverse Academy. This story started in that conversation.

  • Critique of the first build

    Nathasinee Peechaphand

    She saw the first internal release and was honest about the UX and the UI. Small repairs were never going to be enough, so the whole interface was designed again. This site carries the second version.

  • How the ride feels

    Natthaphorn Arsawang · Rapeepat Kaewprasit

    They reviewed the UX and the UI, and above all the scroll ride. They tried it on their own machines, and it did not feel the way it feels on mine. Speed and acceleration tuned at one desk are not the same for everyone.

  • The outside read

    Suthep Chanchuphol

    She said at the start that this way of showing work is not for her. She read all of it anyway, and her notes were some of the most useful of all. Several parts of this page changed because of them.

The rest of this page is about the engine. Any mistake left in it is mine.

More than 3,000 kilometres on a motorcycle gave birth to this portfolio.

In May 2026, after I left the company, I rode to Nan, more than 650 kilometres from home. By then, several journeys had taken me through Korat, Kanchanaburi, Chumphon, Chanthaburi, and Nan.

The saying “life is like a journey” had stopped feeling abstract. After more than five years in software, my life had begun to take the shape of a long road—stretching far behind me and continuing beyond the horizon ahead.

That was when the question arrived: What if the journey itself became the portfolio? The visitor would not only read a career timeline. The visitor would travel through it.

I took inspiration from nuuneoi.com/profile and returned to WebGL+WebAssembly experiment from 2021. My first design divided the work in a simple way. Rust compiled to WebAssembly would calculate vertices. Raw WebGL would draw them.

Architectural Overview
RUST → WASMcalculates verticesbufferSubDataRAW WEBGLgl.drawElements(...)

René Descartes wrote, cogito, ergo sum: “I think, therefore I am.” That idea has shaped how I see my own work. If this portfolio was meant to represent me, it had to show more than my past work. Its engineering should make "cogito" visible: how I frame problems, make decisions, test assumptions, and revise what fails.

A ready-made library would have hidden "cogito" part

That was a reason to start. Still, not yet a technical reason to ship a custom engine.

The technical case came from a fixed use case. The site already had its own road representation, and the camera was constrained to follow it. The road determined both the camera's movement and heading.

If you came here from my journal page, you have already seen it. The road and props are not conventional 3D models. The road is a ribbon of quads. Most props are textured rectangles placed in 3D space. The engine generates and draws them through raw WebGL. No 3D library sits between the engine and WebGL.

The choice sounds like showing off. For most projects, it would also be a bad choice. So here are the proven results:

  • 58.1KB The whole engine on the wire — road, camera, geometry, scenery, and renderer. The bar compares it with gzipped Three.js by download size, not feature coverage.
  • 0.50ms A typical frame, across all 45,018 of them — against an 8.30 ms deadline.
  • 22.2KB The entire scene — road, props, signs — on the wire, at 9.12% of the JSON it replaced.

The result did not come from ordinary 3D asset work. It came from geographic encoding, binary-format design, WebAssembly memory control, shader debugging, build measurement, and compiler tooling. The visible road was the last link in a much longer system.

Why I did not start with Three.js

For most 3D products, I would start with Three.js.

But I began this website with a road, not a renderer.

Three.js does not provide an encoded polyline → road primitive. I had to write the layer that decodes the route, turns it into usable road samples, and produces drawable geometry. That work existed before the choice of renderer.

From there, I had two valid designs. I could place that road layer on top of Three.js. Or I could make it the centre of a narrow engine. The first design would keep the custom layer and add a general scene system around it. The second would keep only the path this page uses.

I chose the second. I wanted Rust compiled to WebAssembly to generate the geometry used each frame, with WebGL drawing the result. I expected that design to give me more control over the work on the frame path. I did not build an equivalent Three.js version, so it is not evidence that WebAssembly is faster than Three.js.

A general 3D library is useful when a product needs imported models, many types of scenes, or assets that can change after deployment. This page did not. It had one road, and that road already required its own data layer.

That decision settled the rendering path. It left a separate question: how much of a general 3D system did this page still need?

The library question was about scope. I did not need an imported-model pipeline, a general scene graph for unknown scenes, or a material system for arbitrary assets. A tree-shaken Three.js build can remove unused modules. That is not the point. I did not want to adopt a general model for a scene whose constraints were already fixed.

The road was data before it was geometry

The road is not a mesh. It is not a list of vertices. Not even a GeoJSON. It is an encoded polyline — the same compact string format online maps use to squeeze a route into a URL. Each point is stored as a small difference from the previous one, zig-zagged so that negatives stay short, then packed into printable characters a few bits at a time. Elevation rides along in a second string encoded the same way, in decimetres.

At startup, the engine decodes latitude and longitude, projects them onto a local plane, and builds a table of road samples. Every later question — where is the centre line at 400 metres, which way is "right" here, where does this signpost stand — is answered from that one table.

One road, character by character

Here is a real road, put through the real decoder. It is 29 characters long. It holds 8 points, and the engine measures it at 159.28 metres. It is a short specimen, chosen so that every character fits on this page. It is not the 2.3 km road the site rides, which is the same format at a larger size.

Before — the whole road, 29 characters
acbtBybngRqAq@Y?KJ?NHPl@l@\j@

That is the entire road. There are no coordinates in it that you can read, and that is the point.

The string is not one number. It is a chain of small ones. Read it left to right and it breaks into groups. Each point takes two groups: first a step in latitude, then a step in longitude.

The same string, cut into its groups
P0 lat acbtB
P0 lng ybngR
P1 lat qA
P1 lng q@
P2 lat Y
P2 lng ?
P3 lat K
P3 lng J
P4 lat ?
P4 lng N
P5 lat H
P5 lng P
P6 lat l@
P6 lng l@
P7 lat \
P7 lng j@

A group ends at the first character that does not carry a "more" flag. So the groups do not have a fixed width. A large step takes more characters. A small step takes one.

How to read one group

Take the first group, acbtB. Each character carries five bits. Three steps get the bits out.

  1. Subtract 63. The encoder added 63 to every character so that the result lands in the printable part of ASCII. That is what lets a road live in a text field — a URL, a JSON string — instead of needing a binary channel or a second encoding around it. It does not make the string free of escaping: look at the backslash near the end of this one, which a JSON string must still escape. Subtracting 63 undoes the shift.
  2. Keep the low five bits. Bit 5 is not data. It is the "more" flag. If it is set, one more character belongs to this group. If it is clear, the group ends here.
  3. Stack the groups of five, lowest first. The first character holds the lowest five bits. Each character after it moves five bits higher.
The group acbtB taken apart
CharASCII−63Low 5 bitsMore?
a973400010yes — read on
c993600100yes — read on
b983500011yes — read on
t1165310101yes — read on
B66300011no — group ends

Written out in the usual order, highest bits on the left, the five groups spell 00011 10101 00011 00100 00010 — that is 3,837,058. Note the order: the last character supplies the highest bits.

Two steps remain. First, zig-zag: the lowest bit is the sign, so 3,837,058 becomes 1,918,529. Second, divide by 100,000: the grain is a hundred-thousandth of a degree, which is near one metre. So the first latitude is 19.18529°. Keep these two steps in mind. The scene format later in this post uses the same pair on its own numbers.

All 8 points
PointCharactersStep (lat, lng)LatitudeLongitude
P0acbtBybngR+1,918,529, +10,100,28519.18529°101.00285°
P1qAq@+41, +2519.18570°101.00310°
P2Y?+13, 019.18583°101.00310°
P3KJ+6, −619.18589°101.00304°
P4?N0, −819.18589°101.00296°
P5HP−5, −919.18584°101.00287°
P6l@l@−23, −2319.18561°101.00264°
P7\j@−15, −2219.18546°101.00242°

The steps are counts of a hundred-thousandth of a degree. One count is near one metre, so the step of −23 at P6 is about 26 metres on each axis — which is why the next figure gives that edge a length of 35 metres. Only the first point holds a place on Earth. Every point after it is a step from the point before.

Look at what that costs. The first point takes 10 of the 29 characters, because it must say where on Earth this road starts. The other 7 points share the remaining 19. This is the whole idea of the format. You pay once for the position. After that you pay only for movement, and the road barely moves between one point and the next.

From degrees to metres

Degrees are not useful to a renderer. A degree of longitude is not a degree of latitude, and neither is a metre. So the engine does one more step. It puts a flat plane on the Earth at the first point, and measures every other point in metres east and north of it.

The plane is flat, and the Earth is not. Over a few kilometres that error stays below a metre, which is smaller than the width of a lane. A longer road would need a better projection. This one does not.

After — the same points, in metres
PointEastNorthEdge lengthBearing
P00.00 m0.00 m52.61 m29.9°
P126.25 m45.59 m14.46 m0.0°
P226.25 m60.05 m9.18 m−43.4°
P319.95 m66.72 m8.40 m−90.0°
P411.55 m66.72 m10.97 m−120.5°
P52.10 m61.16 m35.18 m−136.6°
P6−22.05 m35.58 m28.50 m−125.8°
P7−45.16 m18.90 m

The first point sits at the origin, because every other point is measured from it. Each row's edge is the one that leaves that point, so the last point has none. Bearing is degrees from north. Lengths are rounded to a centimetre here; the engine's own reading for the whole road is 159.28 metres.

Now the road is geometry. The difference between one bearing and the next is a turn. A turn spread over a length is a curvature. The engine keeps one segment for each edge, with its length and its curvature, and builds every quad of the ribbon from that.

Read the bearing column and you can see the road. It leaves to the north-east, comes round to due north, then keeps turning left through north-west and west until it points south-west. The bearing falls at every step but the last, so this is one continuous left-hand bend of about 166 degrees. The last edge turns back to the right, by about 11 degrees. All 29 characters held that.

Storing the road this way changes what it is. It is not an imported model or a hand-drawn approximation. I assembled it from compact route segments from several of my own rides, including rides through Chanthaburi and Nan. The site joins those segments into a road of about 2.3 km. The renderer generates its geometry from that data.

The scene format, and the 91% I got back

The road is only the road. A scene also needs every prop, every sign, every patch of ground cover, and the animation curves that move them. I wrote that description in JSON first, because JSON is what you reach for. It was also the largest thing the page downloaded.

So I replaced it with a binary format of my own. This was the largest wire win in the project.

Scene description, on the wire
JSON
100%
field names alone were 51% of the object bytes
X2 binary wire
9.12%
22,176 bytes for the whole showcase route

Three ideas do the work. None of them is complicated.

  1. Do not send the field names. JSON repeats "lateral", "elevationM" and "rotationDeg" for every object. On my own data the key names were 51% of the object bytes. More than half of the payload restated a schema that both sides already know. The binary format is positional. The fields arrive in a fixed order, and the names never travel.
  2. Do not send full-precision floats. Each number gets a grain that matters. A distance lands on a centimetre. An angle lands on a tenth of a degree. A time lands on a hundredth of a second. The encoder multiplies by the grain, rounds to a whole number, and writes that number in as few bytes as it can. The error stays below half a grain, for every input.
  3. Do not send the defaults. One header byte per object records which optional fields are present. A field that sits at its default value costs no bytes.

The result: 22,176 bytes for the whole showcase route. That is a 90.88% cut, and the round trip is proven against the original. The scene now costs less than the average favicon.

One scene, all of it

Here is a real scene, put through the real encoder. It has a road, two lane spans, four ground-cover spans, and one animated prop. As JSON it is 1,077 bytes. As X2 it is 208 bytes. Every byte below came out of the shipped Rust encoder, and a second pass read all of them back and compared each field to the original.

This is what the encoder was given.

Before — the same scene as JSON, 1,077 bytes minified
{
  "polyline": "}xrrA}a`fRxTvDXDlARnZfFJ@FBHDBFnBnIHb@l@fCLJHCHK@IKqJ@IFKFEtg@uR",
  "elevation": "??_X???????nd@fE?????????",
  "laneSpans": [
    { "start": 0, "end": 50, "laneCount": 3, "dividerPattern": 0,
      "dividerColor": [1, 1, 1], "dashOn": 1, "dashGap": 3 },
    { "start": 50, "end": 100, "laneCount": 2, "dividerPattern": 1,
      "dividerColor": [1, 1, 0], "dashOn": 1, "dashGap": 3 }
  ],
  "fields": [
    { "start":  0, "end":  30, "side": 2, "width": null, "sprite": 0, "cellSpan": [1, 1] },
    { "start": 30, "end":  60, "side": 2, "width": null, "sprite": 1, "cellSpan": [1, 1] },
    { "start": 60, "end":  90, "side": 2, "width": null, "sprite": 2, "cellSpan": [1, 1] },
    { "start": 90, "end": 120, "side": 2, "width": null, "sprite": 3, "cellSpan": [1, 1] }
  ],
  "fieldsAtlas": { "cols": 2, "rows": 2, "pxWidth": 512, "pxHeight": 512 },
  "scenery": {
    "atlas": { "cols": 8, "rows": 8, "pxWidth": 1024, "pxHeight": 1024 },
    "sprites": [ { "sprite": 4, "worldSize": [1.5, 1.5] } ],
    "objects": [ … 1 object, shown in full further down … ]
  }
}

Six members go in. Here is what each one costs on each side. A member's JSON cost is its key, its value and its comma, so the rows add up to the file.

Part by part — JSON against X2
PartAs JSONAs X2Smaller by
lane spans (2)216278.0×
ground-cover spans (4)299358.5×
ground-cover atlas63610.5×
scenery — atlas, sprite table, 1 object380478.1×
the whole scene1,0772085.2×

Bytes, both columns. The two road strings barely move, because they are already compact. The structured parts fall by eight to eleven times. One row goes the other way: JSON pays for its outer brace, and X2 pays for a version byte plus a byte that says this scene carries no text.

And this is what came out.

After — the whole scene, 208 bytes, in frame order
wire version 1B
04
polyline, carried verbatim 65B
407d787272417d616066527854764458446c41526e5a66464a404642484442466e426e494862406c4066434c4a4843484b40494b714a4049464b46457467407552
elevation, carried verbatim 26B
193f3f5f583f3f3f3f3f3f3f6e644066453f3f3f3f3f3f3f3f3f
lane spans (2) 27B
0200904e0300ffffffc801d804904ea09c010201ffff00c801d804
scenery — atlas, sprite table, object count 17B
01080880088008010400ac02ac02010101
scenery object 0 30B
3900040098758827872700004041901c201a872702872700e707ef2ed00f
ground-cover spans (4) 35B
040100f02e02000001f02ee05d02010001e05dd08c0102020001d08c01c0bb01020300
ground-cover atlas 6B
020280048004
text — absent 1B
00

The same six members, in the same order, plus the two format bytes. The road polyline and the elevation channel arrive unchanged, because they are already compact codecs — they take 91 of these 208 bytes, which is almost half the file. Everything else fell by about eight times. This is also why this small scene shows a cut of 80.7% where the showcase route shows 90.88%. The size of the cut follows how much of a scene is props rather than road.

How to read one number

Take the end of the second lane span, 100 metres. It reaches the wire in three steps.

  1. Quantise. Multiply by 100, because the grain is a centimetre, then round to a whole number: 10000.
  2. Zig-zag. Move the sign into the lowest bit: 10000 becomes 20000. A small negative number stays small, instead of becoming a very large one.
  3. Pack, seven bits to a byte. The lowest seven bits go first. The top bit of each byte says whether one more byte follows. So 20000 becomes a0 9c 01.

Three bytes, and the decoder walks them back the same way. The same three steps carry every distance, every angle and every time in the format. Only the multiplier changes.

The animated prop, byte by byte

These are the thirty highlighted bytes above. The prop sits 75 metres along the road and 25 metres below it. One spring drives its height through two keyframes. It rises to road level, then it drops again as you pass.

Before — scenery object 0 as JSON, 249 bytes minified
{ "s": 75, "lateral": 25, "sprite": 4, "elevationM": -25,
  "scale": 12, "rotationDeg": 180, "pitchDeg": 0, "rollDeg": 0,
  "animate": { "elevationM": {
    "from": -25,
    "keyframes": [ { "at": -25, "value": 0 }, { "at": -5, "value": -30 } ],
    "config": { "preset": "wobbly", "responseDistance": 10 } } } }

Set out over five lines to read. The 249 bytes are the minified form, which is what the file holds.

After — the same object, 30 bytes
BytesWhat it isValue
39header — which optional fields followsee below
00second header (v4) — rotation pivot absentdefault pivot
0400sprite — atlas cell, plain 16-bitcell 4
9875s — distance along the road, as a step from the previous object75.00 m
8827lateral — offset from the centre line25.00 m
8727elevationM — height−25.00 m
00004041scale — the one number that stays a float3212
901crotationDeg — yaw180.0°
20animation slot mask — which property movessee below
1aanimation flags — driver, spring preset, shape of what followssee below
8727from — where the spring starts−25.00 m
02keyframe count2
8727keyframe 0 — release point along the road−25.00 m
00keyframe 0 — target height0.00 m
e707keyframe 1 — release point−5.00 m
ef2ekeyframe 1 — target height−30.00 m
d00fresponseDistance — how far the spring takes to answer10.00 m

Three of those bytes are not numbers. They are bit fields, and they are where the format saves the most. Each bit answers one yes-or-no question that JSON answers with a whole key.

Three bytes, one question per bit
39
0 roll
0 pitch
1 rotation
1 scale
1 height
0 shape
0 oscillate
1 animate

One animation, a non-zero height, a non-default scale, a rotation.

20
0
0 s
1 height
0 lateral
0 scale
0 roll
0 pitch
0 rotation

Of the seven animatable properties, only height moves.

1a
0
0
0 resp. duration
1 resp. distance
1 multi-keyframe
0 preset hi
1 preset lo
0 driver = time

Driven by distance, spring preset 01 (wobbly), more than one keyframe, a custom response distance. The response duration sits at its default, so it costs nothing.

Now compare. That object is 249 bytes of JSON and 30 bytes of wire. Of the 249, 184 are key names and their punctuation. "pitchDeg":0 and "rollDeg":0 cost 25 more, with their commas. On the wire they cost nothing. They are two clear bits in a header byte that had to exist anyway. The four ground-cover spans work the same way. Each one uses a single atlas cell, which is the default, so not one of them pays for it.

The road strings show the other half of the argument. A format wins where the data is repetitive and structured. It wins nothing where the data is already dense. So the encoder does not touch the polyline at all.

Two details give me quiet pride. First, "lossy" is a promise here, not a shrug. The error bound is absolute, and it does not depend on the input, so I can state it instead of hoping.

Second, one part of the format refused to be lossy: the glyph rectangles of the text atlas. Rounding those would smear every letter on every sign. The font atlas has power-of-two dimensions, and a divide by a power of two is exact in floating point. So the decoder rebuilds the original float exactly, with no rounding at all. The encoder proves this for each font and for each glyph. If a font ever breaks the rule, the encoder refuses to write it. It does not drift in silence.

Everything is a rectangle

People use “low-poly” as a compliment. This engine has no imported 3D meshes or model files, no vertex normals, and no general material system. It generates every visible shape from rectangles.

The road is a ribbon of quads laid end to end. Each tree, sign, person, and prop is a textured rectangle that faces the camera. The ground cover is a grid of quads. Each letter is another rectangle that shows one region of a font atlas.

That constraint gives back more than it takes:

  1. There are no model files to parse. The engine has no glTF parser, OBJ loader, or general scene graph. It decodes its route and scene data, then generates the required geometry. The visual art comes from 2D sprites in an atlas.
  2. Vertex-buffer storage is allocated once. Each object contributes exactly four vertices. The view distance limits how many objects can appear on screen. Before the first frame, the engine calculates the maximum vertex-buffer capacity for each pass and allocates that storage. During the frame loop, bufferSubData overwrites only the used range. The engine does not call bufferData again, so the frame loop never requests another allocation.

This bound depends on fixed primitives and a fixed view distance. Supporting arbitrary meshes would remove that guarantee.

The tools I had to build first

The engine was half the work. The other half was the tools that make an engine usable.

The first tool was an editor. I called it RoadForge Studio. It draws the route on a map. It places props along the road. It sets lanes, ground cover and speed zones. It plays the result back at ride speed, and it exports the scene the engine reads. Hand-editing coordinates in a config file works for ten objects. It does not work for two hundred.

RoadForge Studio — one prop, one panel

Click the picture to read it full size.

Every property is a control. A number goes in a field. A motion channel turns on with a checkbox. A spring preset comes from a dropdown. A keyframe is a row you add with a button. You set a value, you watch the road, and you set it again. This is fine for one prop.

I built the whole ride this way. It took a month.

Then the story changed. Not the props — the story. One part had to move earlier. One part had to become two parts. One part had to go.

The same ride, as the editor lists it

Click the picture to read it full size.

Look at the list. Eleven rows read cell 59 @ 1100 m. Nothing separates them. That is the whole failure in one picture. The editor knows where each object is, because a metre is a number it can store. It does not know what the object is for. It does not know when the object should act, or how it should move. Those three answers lived only in my head.

The saved project file says the same thing in another form. It holds 275 objects, 374 keyframes and 258 shape expressions, in 12,026 lines and 243 KB of JSON. The only identifier on an object is a generated UUID. To move one part of the story 20 metres earlier, I had to find every object in that part and re-type every number that mentioned its position. The editor gave me no way to say “these belong together, move them.”

I did not want a better editor. I wanted to state the scene instead of assembling it.

So I wrote a language. It starts from four questions. What stands on the road? Where does it stand? When does it act? How does it move? A person must be able to answer all four in text. The compiler then does the translation I had been doing in my head.

Beat 1, complete — 38 lines, comment header elided

Click the picture to read it full size.

Read the four answers off that file.

  1. WhatLET sign_CLEVERSE_s50 = SIGN "CLEVERSE". The object has a name. Everything else in the file refers to that name.
  2. Wheres = 50m, lateral = -13m, elevationM = 13.5m. Distance along the road, offset across it, height above it.
  3. WhenAT sign_CLEVERSE_s50 - 30m. The cue is relative to the object, not to the road. The sign starts to move 30 metres before you reach it. Move the sign, and the cue moves with it.
  4. HowBY SPRING(to -14m) OVER 15m. A spring, a target, and the distance it has to settle in.

Those 38 lines compile to three objects. Here is one of them.

After — one of the three, in the file the compiler writes
{
  "kind": "sign",
  "s": 50,
  "lateral": -13,
  "elevationM": 13.5,
  "repeat": null,
  "text": "CLEVERSE",
  "heightM": 5,
  "color": [1, 1, 1],
  "animate": {
    "lateral": {
      "from": -13,
      "keyframes": [
        {
          "at": -30,
          "value": -14
        }
      ],
      "config": {
        "responseDistance": 15
      }
    },
    "elevationM": {
      "from": 13.5,
      "keyframes": [
        {
          "at": -30,
          "value": 16
        }
      ]
    },
    "heightM": {
      "from": 5,
      "keyframes": [
        {
          "at": -30,
          "value": 3
        }
      ]
    }
  },
  "id": "93db08e5-8ed2-4bc6-a549-fcbaef31501c"
}

Arrays are folded onto one line to fit this column. The id is generated at compile time. Nobody edits this file.

The language then grew what a language needs. It has constants, functions, a case expression, keyframe blocks, and an import directive. The import directive is the part that answered the original problem. Each beat lives in its own file. Each beat is written around its own anchor. One root file states the running order, and nothing else.

ride.beat — 5 of its 19 import lines
IMPORT BEAT taDuty FROM "./6.TA-duty.beat" ANCHOR 380m
IMPORT BEAT powDemo FROM "./6.TA-duty.beat" ANCHOR 455m
IMPORT BEAT finalProject FROM "./7.final-project.beat" ANCHOR 590m
IMPORT BEAT beatVIII FROM "./8.beat-viii.beat" ANCHOR 760m
IMPORT BEAT composeDeploymentIdea FROM "./9.compose-deployment-idea.beat" ANCHOR 880m

The whole running order is 19 lines like these, inside a 53-line file. It declares no object of its own. To move a part of the story, change one integer. The beat brings its props, its cues and its motion with it, because every position inside the file is written against that file's own anchor. The afternoon of arithmetic became one edit.

Two more tools had to exist before the first beat file did.

The first was a translator. A month of work sat inside the old project file, and I was not going to type it again. translate-json-to-beat.mjs reads the JSON, groups the objects by position, and writes beat source rebased onto each beat's own anchor. It gave me a corpus to edit instead of a blank page.

The second was the toolchain around the language, because a language without tooling is a worse config file. The compiler is built on Langium, so the VS Code extension gets syntax highlighting, live diagnostics and go-to-definition on the names. A misspelled prop name is now an error with a line under it. Before, it was a prop that silently did not appear.

Motion follows the same rule. I did not hard-code the curves in Rust. The engine carries a small expression evaluator, and each shape arrives as a compiled program on the wire. The source text never reaches the engine. The decoder checks a program for stack safety before it runs at all. A malformed file fails at the boundary. It does not crash in the middle of a frame a thousand frames later.

The curves themselves are written in the language. Projectile motion, a spring, a spiral and a circling path are four of the eight functions in one shared file that seven beats import. A parabola is written once.

Written down, it is absurd: a compiler and a language server, to place cartoon props beside a fake road.

Here is what it bought. Run the compiler on ride.beat. It reads 18 files. It emits 275 objects. Those objects match the ones in the project file the editor saves, field for field, apart from the ids it generates as it goes. The objects are the whole claim. The route, the lanes and the ground cover are constants the compiler copies out, and speed zones and ride stops stay with the editor. The language models none of those four.

The ride is no longer one file that nobody can edit safely. It is 16 beat files, one running order, and one shared library. I can read them, diff them and re-order them.

Where the work happens

Three execution layers take part in each frame: Rust/Wasm, JavaScript, and the GPU. Rust/Wasm and JavaScript both execute on the CPU. The whole design is about giving each layer only what it is good at.

Once, at startup
decode scene(22,176 bytes)build road sample tablepositions every laterquestion is answered fromsize GPU buffersto the worst casethat can ever occur
Every frame — target 8.30 ms, the two CPU columns take 0.50 ms typical
RUST / WASMadvance player along the roadbuild camera basispick the visible windowcull: behind / far / pastsort billboards far → nearwrite 4 corners per objectevery decision lives hereJSupload bytesset uniformsissue drawsno maths here7 µs × 3–4 writes,25 µs totalGPUproject cornersperspective ÷sample atlasblendper pixel —the easy part5 passes: sky (static), ground cover, road, scenery, text

Rust decides. The GPU draws. JavaScript only carries messages.

Rust owns everything that requires a decision: where the player is, where the camera looks, which objects are close enough to matter, what order transparent things must be drawn in, and the four world-space corners of every rectangle. All of it is integer-and-float arithmetic over a table that was built once at startup. There is no allocation in that loop.

The GPU owns everything that has to be repeated a lot with no decisions in it. Each corner arrives in plain world coordinates and the vertex shader does the perspective divide itself — subtract the camera position, project onto the camera's forward and right directions, divide by distance. Doing that on the GPU is nearly free, because it is the one thing graphics hardware exists to do, in parallel, thousands of times at once. Then the fragment shader samples the atlas and blends, once per pixel.

JavaScript, notably, computes nothing. It binds a buffer, copies bytes, sets a handful of uniforms and issues the draws — five passes, in order: sky, ground cover, road, scenery, text. Every number it passes through was decided in Rust or will be consumed by a shader. It is a courier, and keeping it a courier is deliberate: it is the one layer of the three with a garbage collector, so it is the one layer where allocating in the frame loop could introduce a pause I do not control.

Most passes are a single draw call for the whole thing — every quad in the road, or every billboard in view, goes down in one. Text is the exception: each sign gets its own draw, because each one needs its own colour. That is the one place the engine trades a few extra calls for something an artist would notice.

And one architectural exception, which I would rather name than let you find. Text is still projected on the CPU — its vertices arrive already in screen coordinates rather than world ones, while road, ground cover and scenery all hand over raw world positions and let the shader do it.

Why the frame finishes early

Size is the half people notice. Speed is the half they feel. A 120 Hz screen gives you a hard deadline of 8.30 ms per picture — miss it and the viewer sees a stale frame, which they read as jank even if they could not name it. For scale, a human blink takes more than ten times that long.

I timed it directly on the live, deployed page this time — the engine's own per-frame call, wrapped and read with the browser's own clock, across a full ride through every beat of the heaviest route on the site. This is what came back:

45,018 frames, one full ride
p50 0.5msp90 1.2msp99 1.8ms8.3ms budget0ms2ms4ms6ms

Every one of the 45,018 frames is in this chart — none fall outside it. The single worst one recorded, 4.7 ms, is that last, barely visible bar before the gap. The empty space after it is 3.6 ms of budget going unused, every single frame of the ride.

A typical frame uses well under a tenth of the time it is allowed. Even the single worst frame recorded — one outlier among tens of thousands, from the newest and heaviest route on the site — still made the deadline, with more than 3.5 ms of budget unused. Three things buy that headroom, and none of them is clever:

  1. Nothing is thrown away, so nothing has to be swept up. My frame loop stores its world state in reusable WebAssembly memory. It does not allocate JavaScript objects for the geometry handoff, so that path creates no garbage for the collector.
  2. Geometry takes no detour. This one deserves its own explanation, below. Measured cost of the whole step: 24.9 µs per frame. Near enough to free.
  3. Code I do not need never ships. The source has two faces — a rich internal design tool, and the lean public build. The compiler proves which lines the public build can never reach and deletes them, right down to the formatter that would print a crash message.

There is a fourth thing that is really a decision rather than an optimisation: the blended passes have no depth buffer. Everything that needs to blend — scenery cards, signs — is sorted by camera distance and drawn far to near, the way a painter lays down a background before the foreground. Sorting a few hundred cards costs less than the memory and bandwidth a depth buffer would, and it composites transparency correctly, which a depth buffer alone does not.

The text is worth a note too. Every sign you drive past is drawn by the engine, not by the browser: a row of textured quads pulled from a pre-baked bitmap font atlas, positioned by the same geometry as everything else. No DOM elements floating over the canvas, no layout pass, no second rendering system to keep in sync.

Two words that saved the frame: bufferSubData

Getting geometry to the graphics card happens every single frame, no matter what else the engine does, so it is worth being exact about. WebGL offers two ways to put data in a buffer, and the difference between them is the difference between renting and owning.

The two ways to fill a buffer
bufferDataallocate a block thissize and fill itthe old contents are gone;the driver may replace ororphan the backing storagebufferSubDatawrite these bytes into theblock I already ownno new store is specifiedfor this write — the memorywas reserved once

Calling bufferData once per frame is the obvious thing to do, and it is what most examples show. It also means re-specifying storage 120 times a second while the card may still be reading the previous contents. Drivers are built to cope with that. I did not want to find out how well, on every machine a visitor might own.

So the allocation happens once, before the first frame — sized, as the earlier section described, to the largest this scene could ever need, and flagged as storage that will be rewritten constantly so the driver can place it accordingly. Every frame after that is a write into memory the engine already owns. Four passes work this way: road, ground cover, scenery, text. A fifth, the sky, is a flat quad that never changes, so it is uploaded once and never touched again.

How much of each buffer a frame actually writes is not a detail I want to hand-wave, so I measured it: the bars below step through 25 recorded frames from a ride down the whole route. Road and ground cover sit near their ceiling through most of it — both span the full view distance, so there is little for them to vary. In a handful of the sampled frames they drop well below it. I have not pinned down what puts them there, and I would rather leave the reading on screen than tidy it away.

The other two are sparse, and they follow the scene rather than the view distance. The text pass is the interesting one: with no sign in front of it, it does not write at all — the upload is skipped, not shrunk. Over the timing run that produced the numbers under the chart, that was 44% of frames.

Every frame, per pass — into storage allocated once at startup
refill in placeRust rewrites its ownvertex memory each framehand back a viewa window onto Wasmmemory, not a copybufferSubData(view)overwrite just therange actually usedLIFETIME RULEUpload before the next call into Wasm.If Wasm memory grows it can move, and the view is left pointing at nothing.PRE-ALLOCATED GPU BUFFERS — 189.5 KB, ALLOCATED ONCEWRITTENCAPACITYroad98.2%62.8 KBground cover89.4%57.4 KBscenery4.1%15.5 KBtext2.2%53.8 KB
Act I · frame 360

Measured live on the deployed build — 25 frames sampled from a 21,811-frame ride, five from each act. Drag to stop on one.

3 or 4 writes a frame · 7 µs each · about 24.9 µs a frame

The view in the middle of that path is the part worth respecting. It is a real JavaScript object — that much is unavoidable — but it is not a copy: it is a window looking straight into WebAssembly memory. That is what makes it fast, and it is also what makes it fragile. So the upload happens on the very next line, with no call in between, and the comment in my code says exactly why, because this is otherwise the kind of thing that gets broken a year later by someone reasonably tidying up.

One more thing is uploaded once and never again: the index buffer, the list describing which corners form which triangles. It is identical for every rectangle forever. It also pays for itself — two triangles share two corners, so every rectangle costs 4 vertices instead of 6, a third less vertex data on the one path that runs 120 times a second.

Add it up: one allocation per pass at startup, at most one write per pass per frame, and no JavaScript-side copy anywhere in between. Timed live on the deployed build, each write averages 7 µs, and a frame spends about 24.9 µs on the handoff altogether. What I am not claiming is zero copies outright — on the far side of that call the driver still moves the bytes into storage it manages, and that part is not mine to control.

What it weighs, and what it took to get there

A size comparison is fair only if both sides use the same compressor. A third-party tracker measures the full Three.js bundle at 178.5 KB with gzip. I compressed my own engine with gzip too, for a fair comparison.

Compressed download size — gzip, both sides
Three.js
178.5KB
gzip · full bundle, before it draws anything
My engine
58.1KB
gzip · road, camera, geometry, scenery — already drawing

The Three.js figure comes from a third party. It measures the full bundle. The bundle is not tree-shaken. A project that uses only a few pieces of Three.js can make the bundle smaller. Matching the compressor removes one variable. It does not remove every caveat.

The engine I actually ship is smaller. I compress it with Brotli, not gzip. The raw file is 144 KB. The Brotli file is 47.8 KB. This is the real download size for this page.

Getting to this size took work. I tried many changes to shrink the binary. Most changes did nothing. Two changes made the binary bigger.

Build settings, measured on the shipped binary
SettingEffectNotes
Two build flavours Helped: −39.0% compressedThe public build compiles without the studio-only feature set.
-Cpanic=immediate-abort Helped: −19.6% compressedDeletes std’s panic-message formatter. The single biggest lever.
codegen-units = 1 Helped: ≈ −1%Small, real, and free at runtime.
lto = "fat" Did not help: +84 bytesSlightly worse, and about 7× slower to build.
opt-level = "z" Did not help: +1,632 bytesLess inlining gave wasm-opt less to eliminate later.
strip = true Did not help: build brokenDrops the section wasm-opt validates against.

I started with the config decoder. The decoder used serde, a general-purpose deserialiser. Serde can read almost any data shape. I replaced serde with a hand-written decoder. My decoder reads only the twelve structs a scene actually uses. Serde alone added about 24 KB to the compressed binary. My decoder adds under 5 KB. The binary shrank from 63.9 KB to 44.6 KB, a 30% cut. A decoder that can read any shape is heavier than a decoder that reads only the shape you send it.

Next, I split the build into two flavours. The engine also runs an internal editor. The editor needs a JS-object constructor, a scene encoder, and an authoring-only decoder. A visitor to this page needs none of this code. I put all of it behind one Cargo feature, named studio. I write #[cfg(feature = "studio")] above each function that needs it, for example pub fn new(config: JsValue). Without the studio feature, the compiler removes the function. There is nothing left to link or strip. The public build uses one constructor only: from_config_bytes. This constructor reads a prebuilt binary scene, not a live JS object. Compressed, the binary shrank from 78.3 KB to 47.8 KB, a 39% cut — the largest single saving in this project, and the same 47.8 KB I quote above.

Last, I changed the panic strategy. About 19 KB of the binary was float-formatting code. This code builds panic messages from numbers. My own errors cross into JavaScript as short fixed strings. They never contain formatted numbers. An immediate-abort panic strategy deletes this code completely. The compressed size dropped by 19.6%.

These three cuts do not add up to one total. I measured each cut against a different baseline. But they share one lesson: code that handles a case this page never hits is not free. You must find it and remove it.

The failures matter too. Most people try fat link-time optimisation first. I tried it too. It made the compressed output 84 bytes larger. It made the build seven times slower. I tried opt-level = "z", which optimises for size. This made the binary bigger — less inlining left the WebAssembly optimiser less dead code to remove later. I tried stripping symbols. This broke the build. I found a cluster of near-identical duplicated functions. Removing them looked like an easy 27 KB win. But the compressed size did not change — the compressor had already removed the duplication. Raw bytes never mattered. Compressed bytes mattered.

Measure the file you ship. Do not measure the file your build produces along the way.

Getting the pictures there

The art needs transparency. WebP preserves the alpha channel while allowing lossy compression. I re-encoded the same three atlases as PNG at identical dimensions. The WebP files were 6.8× to 12.7× smaller. The largest is 383 KB as WebP and 3.0 MB as PNG.

This is a download saving, not a GPU-memory saving. The browser decodes both formats to pixels before upload, so they occupy the same video memory.

Summary

A ride became a road, and the road needed a page. Here is the short form of what is under it.

  1. The road is data before it is geometry. Encoded text holds the route, and a second string holds its elevation. The engine decodes them at startup, puts them on a flat plane, and builds a table of road samples. Every later question reads that table.
  2. Field names were 51% of the object bytes. So my binary format sends none of them. Each number gets a stated grain, and one header bit covers each default. The showcase route travels in 22,176 bytes.
  3. Four vertices per object, and a view distance that never changes. Those two limits give a maximum size for every buffer. The engine allocates each one before the first frame, then writes into memory it already owns. Rust decides, the GPU draws, and JavaScript carries the bytes between them. A typical frame takes 0.50 ms of the 8.30 ms a 120 Hz screen allows.
  4. Code this page never reaches does not ship. The editor code sits behind one build flag. The formatter that would print a crash message is deleted. The engine downloads at 47.8 KB.
  5. The editor was not the last tool I needed. It placed a prop well, and it could not say what the prop was for. The ride is now 16 beat files, one running order, and one shared library. To move a part of the story, I change one number.

This engine fits one page, and that is why it works. The constraints were fixed before I wrote any of it: one road, one camera path, no imported models, and no assets that change after release. A library handles everything this engine does not, and it handles it well. My problem was narrow, so the answer could be narrow too.

The question at the top of this page was whether the journey itself could be the portfolio. The road answers that for a visitor. This page is the other half of the answer — how I frame a problem, what I measure, and what I do when the first design stops working.

The road on this site is 2.3 km long, from more than 3,000 kilometres of riding.