How the hill was built.
One concept study, one signature move: a single climb value between 0 and 1 that drives the vegetation, the fog, the air temperature and the light at once. Everything you see on the line is downstream of that one number. Here is the brief it started from, the engine at the centre, and what each critique pass caught.
A hill railway you climb with your hand.
The director's brief was tight. It named the place, the palette, the type and one thing to prove. The rule for this wave: start from a world, not a wireframe — so the whole page had to be the Penang Hill funicular, not a page about it.
- NameBUKIT BENDERA LINE — the hill railway, Penang, 833 m
- ConceptDrag the funicular car up the hill and feel the climate change — station by station, fog thickens, air cools, jungle turns to mist.
- Palettedawn blue rail red jungle summit mist
- TypeLibre Caslon Text display · Mulish text
- SignatureOne progress value (drag the car or scroll) drives layered parallax vegetation, fog density, temperature readout and light. The altitude gauge is the scrollbar.
- VoiceA conductor who has run the line forty years.
One value. Five keyframes. The whole mountain.
The climb is a single smoothed number, progress, eased toward the scroll position every frame. Instead of animating dozens of properties by hand, the scene stores five biome keyframes down the hill — sky colours, vegetation tint, fog and cloud density, particle colour, temperature — and interpolates between them by progress. Ask the engine for any property at any height and it hands back the blend. The temperature gauge, the fog banks and the tree colour are all reading the same table.
// biome keyframes down the hill: p, sky bands, veg, fog, cloud, particle, temp var K=[ {p:0, /* Air Itam */ fA:.05, cA:0, t:31, /* … sky/veg colours … */}, {p:.28, /* fern slope */ fA:.13, cA:.05, t:28}, {p:.55, /* montane */ fA:.30, cA:.36, t:24}, {p:.8, /* cloud line*/ fA:.43, cA:.6, t:21}, {p:1, /* summit */ fA:.5, cA:.8, t:19} ]; function seg(p){ // find the two keyframes p sits between var i=0; for(;i<K.length-1;i++){ if(p<=K[i+1].p) break; } var a=K[i], b=K[i+1], u=(p-a.p)/((b.p-a.p)||1); return[a,b,clamp(u,0,1)]; } function num(key,p){ var s=seg(p); return lerp(s[0][key],s[1][key],s[2]); } // …so fog, cloud, temperature, tree tint all ask ONE table: var fA=num('fA',p); // fog alpha at this altitude var temp=num('t',p); // °C, drops 31 → 19 up the line
Because one number owns the mountain, dragging the car, scrolling, and pressing the arrow keys are three doors into the same state — no separate animation timelines to keep in sync. The counter-weight car that slides down past you at the passing loop is the same idea: a small window on progress around the halfway mark, faded in and out with a sine.
The loop is the level.
The first draft is never why a scene looks finished. Five screenshot passes — desktop and mobile, top, middle and bottom — read like a hostile art director, each ending in one fix and one new complication.