Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
Private static browser game. Touch-first Norwegian UI, on-rails driving with real branching roads and stops at junctions. Main trip: Herlofsons veg 3A → parking access by Skjermvegen barnehage, about 3.1 km.
npm install then npm run dev. The published static entry point is dist/index.html. Dependencies used at runtime are bundled locally; there are no map or elevation requests from the player's browser. Vite is used only for development.檢視完整 README ↗Private static browser game. Touch-first Norwegian UI, on-rails driving with real branching roads and stops at junctions. Main trip: Herlofsons veg 3A → parking access by Skjermvegen barnehage, about 3.1 km.
npm install then npm run dev. The published static entry point is dist/index.html. Dependencies used at runtime are bundled locally; there are no map or elevation requests from the player's browser. Vite is used only for development.
10.325,63.393,10.369,63.406. Attribution is present in the UI. Derived data is in dist/map.json.Three.js 0.170.0, MIT license in dist/vendor/THREE-LICENSE.txt. iPad uses WebGL2 with capped resolution, batched geometry, distance culling, and local shadows. If WebGL is disabled, a lower-resolution software 3D rasterizer provides a fallback. The cloud QA browser lacks WebGL; hardware rendering and physical iPad performance remain unverified.
node verify-game.mjs runs the actual game state machine with a mocked renderer and DOM. It covers start, invalid choices, complete 3 km route, victory, replay, and an alternate route. Browser UI start/choice controls were checked with the software renderer. WebMCP is feature-detected; the cloud browser does not expose modelContext, so browser registration validation is unavailable.
Run python fetch-osm.py (it downloads both documented OSM boxes and merges them into byasen.osm), then python prepare-map.py and python fetch-terrain.py. The terrain script uses the public Kartverket API with up to 50 points per request, 6 concurrent requests, and caches responses. Source downloads and transient cache files are ignored by Git. No API keys are needed.
At each roundabout entry the game offers every exit that is not a blindvei once, numbered in travel order; if only one exit leads on, the car drives through the circle by itself. Direction arrows use the outgoing road relative to the approach, and no exit is visually recommended. Selecting an exit creates one continuous path through the directed OSM roundabout; speed is capped at 25 km/h inside the circle. “Angre rundkjøring” restores the last entrance, distance counter and choices, during or after the turn. Restart and arrival clear the undo point.
Run node verify-roundabouts.mjs for every mapped entrance and exit, including touch selection, automatic traversal and undo.
Free driving is available in settings: automatic acceleration up to 90 km/h, A/D or arrows to steer, Space to drift, Down/S to brake. Q and the recovery button snap to the nearest road with the tangent closest to the previous body heading, independently of OSM node order. The guided trip accelerates to 200 km/h between decisions.
The user-marked Ugla school through road uses OSM ways 1366229200, 89285927 and the southern portion of 89285932, connecting Per Sivles veg to Uglavegen without a decision for the school-yard spur. Existing OSM building footprints remain approximate extrusions.
OSM bridge/layer tags are retained for Gråkallbanen. Munkvoll bridge 14012126 over Byåsveien/Fv6650 has a grey steel deck, railings, concrete abutments and roadside V supports based on the supplied Street View image. Deck height and approach banks are visual estimates, constrained to clear the road; the terrain grid does not survey bridge geometry. The other mapped tram bridge is also elevated. Bus shelters follow their nearest road tangent with the open side toward the kerb; their entire footprint is outside neighbouring carriageways.
node verify-transit.mjs checks bridge clearance/approach continuity, all 19 bus shelters and both directions through Ugla. node verify-free-drive.mjs checks recovery orientation in both directions and node orders, speed, drift and collisions. Local software renders check the scene; physical iPad/WebGL appearance remains unverified.
See docs/junction-references.md and docs/junction-photo-matches.json for scope and provenance. All mapped branching junctions receive a nearby detail pass; 24 footprints have new photo-informed overrides. This is not complete photographic reconstruction of every intersection. Run node verify-junction-buildings.mjs for footprint/roof validation.
By default dead ends are no longer offered as choices; “Blindveier” in settings turns them back on. (From 29 September to 11 October 2026 the setting was on by default; since 11 October 2026 it is off again, see the last section.) A road counts as a blindvei when neither the kindergarten nor home can be reached from it without turning around or driving back through the same junction; this includes no-through residential loops and roads that end at the map edge. Home and the kindergarten entrance stay selectable. When only one road is left, for example a right turn where straight ahead is a blindvei, the car drives on by itself and keeps its speed until the next real choice, braking only within 55 m of it; an automatic roundabout is entered at circle speed. The main trip has 17 choices instead of 26. With blindveier on, every road is offered and the car slows at each junction as before.
Run node verify-dead-ends.mjs to explore every junction reachable from home and check that no offered road is a dead end, that speed is kept through automatic junctions, and that the setting restores the dead ends.
The car now rides on the drawn road. Roads are drawn as 8 m pieces laid on the terrain (dist/road-surface.js), while the car used to interpolate its height only between map nodes, up to hundreds of metres apart, and sank as much as 3 m below the road over crests. The guided trip now samples the road every 2 m on that same surface, and free driving uses it too. Where two drawn pieces meet at different heights on steep side slopes, the car rolls over the step rather than through it. Centre-line dashes follow the drawn road as well. Run node verify-road-height.mjs to drive every road against the drawn surface.
Arrows for the next open junction appear while the car drives, once that junction is within 250 m. Tapping one (or using the arrow keys) queues that road, up to two junctions ahead. The picked arrow lights up yellow for 0.8 s before any new arrows appear, and further taps are ignored meanwhile. The hint and the floating cue over the car show the plan. The car does not slow down for a queued junction; it enters a roundabout at circle speed. Run node verify-queued-choices.mjs for the whole trip on queued choices.
Soothing background music is generated with Web Audio in dist/music.js (pad chords, a soft bass and a music-box melody in F major), with no audio files. It starts with the drive, dips while the voice speaks and stops when the page is hidden. The ♫ button turns all sound (voice and music) on or off, and “Musikk” in settings turns only the music off.
“Musikk” and “Blindveier” are remembered in the browser (localStorage, key sofiatur.innstillinger). If storage is blocked, for example in private browsing, the game starts with the defaults. Run node verify-settings.mjs.
Performance, measured in headless Chromium (SwiftShader) at iPad size; absolute numbers on a real iPad differ:
Float32Arrays instead of plain arrays, and bus shelters, tram tracks and junction buildings look up nearby roads through a grid (segmentIndex in transit-geometry.js) instead of checking all 4,600 road segments. The generated geometry is unchanged.Buildings around the Bøckmans veg / Selsbakkvegen junction at Munkvoll follow Statens vegvesen's road images from July 2025 and a Wikimedia Commons photo of Byåsen skole. Palermo is grey-green with dark tiled roofs, and Sunny Beach Solstudio's signs sit on its west gable and over its door on Bøckmans veg. Sabrura is modelled as a dark green building with a red-brown roof and its badge on the gable facing the side road. The white corner house has Lille Szechuan Byåsen painted on its gable. Byåsen skole gets its ochre-brown brick, name, coat of arms and red stair screens, and the Skolemuseum and neighbouring houses are recoloured. The Boreal workshop is red-brown brick, with concrete pilasters, a tall door and its sign, as in the user's photo. The old yellow and blue tram no. 29 stands on the spur in the depot yard north of the workshop door, with its pantograph up to the wire. Sources and uncertainty are listed in docs/munkvoll-references.md and docs/junction-photo-matches.json. Run node verify-munkvoll.mjs.
Trips to the kindergarten are counted on the device (localStorage, key sofiatur.fremgang). After the first arrival the start screen shows a colour picker for the car: eleven colours, the rainbow and KIWI. The start camera then turns a little so the parked car shows beside the card. After the second arrival the car gets a rainbow trail: seven stripes hovering over the road behind it, fading over 2.8 s or 55 m (dist/rainbow-trail.js). A switch on the start screen turns the trail off and on. Before each reward the start screen hints at the surprise. When a reward is unlocked, the finish card says so and its button leads to the start screen. Colour, trail switch and the number of trips survive a reload; blocked storage starts from scratch. The ET5's chrome badges no longer have a black background, so they suit any paint.
The Palermo junction (Bøckmans veg / Selsbakkvegen) has three traffic lights, one beside each road in (dist/traffic-lights.js). They are a cue, not traffic control: red while the car is on its way, red and amber within 22 m, then green while the car is at the junction, and amber and red again after it leaves. Run node verify-rewards.mjs and node verify-traffic-lights.mjs.
Where two mapped junctions lie under 12 m apart they are now asked as one. At Gamle Oslovei, Kyvannsvegen (up towards Myra barnehage) leaves the road 8 m north of the Nedre Ferstadveg junction; the left turn was only offered after the car had moved on. The short link is no longer a choice of its own: each end offers the other end's roads directly, and the car still drives through both points. Such a road carries stub, the index where it turns off, so its arrow points the right way. Seven junction pairs are merged (map_fixes.py, also run by prepare-map.py). The main trip still has 17 choices. verify-dead-ends.mjs checks Gamle Oslovei and that no junction pair under 12 m remains.
Choosing ahead (arrows for the next junction while driving, queuing up to two roads) is now a menu option, “Velg vei på forhånd”. It is off by default, so the car stops at every choice. It is remembered with the other settings. Switching it off while driving drops any queued roads.
Lakes. Kyvannet and Lianvannet are OSM multipolygon relations, which prepare-map.py (ways only) leaves out. add-lakes.py fetches their outlines from Overpass and adds them as water areas with a flat level and a name label. It also widens the terrain grid west and north (x −800…2280, z −1080…840) so both lakes lie on the ground. Kartverket's height service did not respond when this was built; fetch-terrain.py now covers the wider grid if measured heights are wanted, followed by add-lakes.py. The water is drawn flat, and no trees grow in it. Around the lakes the trees are half as dense. (Since 30 September 2026 the widened strip has real ground and the shore no walls: see Lianvannet below.)
Roundabouts. Cars go round at 45 km/h instead of 25. An exit's arrow now shows where its road leaves the circle, seen from the car as it comes in: straight on, left, right or back. Before, it came from where the exit road ends, so arrows could contradict the exit numbers. At the KIWI roundabout, which the car enters at a slant, Odd Husbys veg is straight on and General Bangs veg the first turn to the left. A one-way splitter island after an exit no longer asks again: at the KIWI roundabout, General Bangs veg is chosen once and the car drives out.
Palermo lights. The fourth arm, the service road north to the Byåsen skole parking (OSM 169742455), is now a road in the network. At this junction every road is offered even with blindveier off, so left, straight on and right are possible from every road in. It gets a traffic light too.
KIWI. General Bangs veg has a junction at the Drivhusvegen entrance just after the roundabout. A short road leads into the customer parking (map_fixes.py). Parking there unlocks a secret KIWI car: KIWI green with the shop's logo on the front doors. It is picked as the “K” colour on the start screen and remembered with the other rewards. The parking counts as a place to go, so it is offered with blindveier off. The main trip has 17 choices, including the KIWI entrance.
prepare-map.py applies the road-network changes from map_fixes.py. Tests: verify-roundabouts.mjs (exit order, KIWI roundabout), verify-dead-ends.mjs (Palermo, KIWI), verify-traffic-lights.mjs, verify-rewards.mjs (KIWI car).
Dalgård skole. The school's buildings are OSM multipolygon relations and were missing: A-bygget with its courtyard, B-bygget and the southern wings. add-dalgard.py fetches them from Overpass. The model follows a 2021 overview photo on Wikimedia Commons, a 2003 archive photo from Trondheim byarkiv and the aerial: one storey of brown brick with dark brown window frames, large low hipped roofs in light grey metal meeting in hips and valleys, glass roof lanterns on B-bygget and both southern blocks, and a brown-roofed hall to the north-east. The name board and a timber gable porch face the car park by Anders Wigens veg.
Extra and Bunnpris. Extra Ugla is on the ground floor of Dalgårdstunet (2024). Hus A is light sand render and five storeys, over a glazed shop front with bronze panels. The red Extra name and badge face Anders Wigens veg and the square. Hus B has gold-khaki cladding and balconies. This follows the sales prospectus illustrations and FINN photos. Bunnpris Ugla has a yellow sign with black letters over its entrance at the car park. No photo of that shop front was found, so its colours come from the aerial.
Down to Dalgård ishall. Pitches were not drawn before, and trees grew on them. Dalgård idrettspark now has its artificial-turf pitches with markings and goals, the tartan running track with lane lines round Byåsen Arena, the tennis and padel courts and the car parks. Dalgårdvegen has a junction where the service road turns off to the ice rink's car park. A short road, Dalgård ishall, ends at a parking place beside the hall. Like KIWI, it counts as a place to go, so it is offered with blindveier hidden and the car can drive all the way down. The school and the ice rink have name labels.
Rebuild order: prepare-map.py, add-lakes.py, then add-dalgard.py. The last two take a saved Overpass answer as an optional argument. Sources and uncertainty: docs/dalgard-references.md. Tests: node verify-dalgard.mjs checks the school roofs over every wall, the open courtyard, the grounds and goals, the signs and the road. verify-dead-ends.mjs checks that Dalgård ishall can be reached with blindveier off.
The map reaches Stavset. The map now continues south of the original box: Odd Husbys veg down to the roundabout at Stavset senter, and Byåsveien back north through the roundabouts at Lysverkvegen and Kystadlia, with the Kystad houses between them (map_fixes.SOUTH). Roads, buildings, areas, bus stops and shops there come from OSM like the rest of the map. A second OSM box is fetched and merged with the first (fetch-osm.py). The road network has 811 road pieces between junctions instead of 617, and there are 5,121 buildings instead of 4,100. The main trip is unchanged, with 17 choices. Kartverket's height service did not answer, so the terrain south of the measured grid comes from Mapzen terrain tiles, matched to the last measured row. The plot under Stavset senter is levelled.
The detour. From home, Odd Husbys veg leads past Bunnpris Ugla to the roundabout at Stavset senter. Enromvegen and Nedre Stavsetvegen lead into Rema 1000's car park, where the road Rema 1000 ends at a parking place in front of the entrance. From there, Byåsveien leads through the three roundabouts and on to the kindergarten. The car park is a place to drive to, like KIWI, so it is offered with blindveier off, and the arrival is announced. Kystadbrua, Byåsveien's bridge over the Kystad valley, has railings. OSM has Byåsveien's two lanes at the Kystadlia roundabout without a oneway tag; they are one-way now. At a roundabout, an exit that leads back to where the car came from is always Snu. At Stavset, Byåsveien's lanes part before the circle, so the angle alone read the way back as a right turn. While the car is south of the original map, the minimap shows a taller overview down to Stavset.
Bunnpris Ugla, checked against the user's photos: the shop is the single-storey OSM building 1037053709, not the two-storey house behind it, where the sign used to be. The shop's gable faces Odd Husbys veg, the road south to Stavset. On the gable are big yellow BUNNPRIS letters with a black outline, the black board with the opening hours (every day until 22) and a yellow Billig middag poster. The black roof with dark red trim runs on over the entrance as a canopy on red posts, facing the car park. Under the canopy are posters, a post box, bins, trolleys, flowers and the Åpent søndag board. The old house behind has white vertical boards, dark red window frames and a smaller BUNNPRIS sign on the roof in front of it. The car park is drawn with bays, and the road Bunnpris from the end of Granlivegen ends in front of the canopy.
Stavset senter follows the user's photos 3 and 4. It has light walls with blue trim, glazed shop fronts and a low grey hipped roof. Rema 1000's entrance is a tall black block with STAVSET SENTER letters and a white portal, with a 7.6 m REMA 1000 logo (red REMA, blue 1000) over the doors. A second, 9 m logo faces Byåsveien; that one is not from a photo. The fronts carry the bakery, pharmacy, hairdresser and florist signs, and granite blocks stand in front. The car park has bays and a blue trolley shelter. Roofs without walls (building=roof, such as the Circle K canopy, Uno-X Munkvoll and shelters) now stand on posts.
Performance, same headless Chromium: building the world takes about 3.6 s instead of 2.9 s, and the scene has 1.69 million triangles instead of 1.33 million. The chunks south of the original box are only drawn within 680 m of the car, like the rest.
Rebuild order: fetch-osm.py, prepare-map.py, add-lakes.py, add-dalgard.py, then add-stavset.py. Sources and uncertainty: docs/stavset-references.md. Run node verify-stavset.mjs. It drives home → Rema 1000 → kindergarten with blindveier off, through the three roundabouts. It also checks the arrival messages, the exit order at the Stavset roundabout, the terrain seam and level plot, the signs on both shops and which way they face, the parking places, the parking bays and the bridge railings.
Start. The start button on the first card drives the car out of the parking place. Leaving it is the only road, so it is no longer a choice: the arrow that used to appear first, pointing straight ahead while the camera still looked back at the house, is gone. The first arrows come at the first junction. “Kjør igjen” and “Begynn turen på nytt” start the same way.
No pause. The game is always running. The pause button is gone, and so are Space (in the guided trip), P and Esc (in free driving) as pause keys, the “Turen er på pause” overlay and the automatic pause when the window loses focus or the page is hidden. Space still starts the trip from the start screen and drifts in free driving. Losing focus only releases the held keys. The settings menu is a modal box over the running game: the car goes on behind it (in the guided trip it stops at the next junction), and a changed Blindveier setting applies when the menu closes. read_drive_state no longer has a paused field.
Blindveier were on by default until 11 October 2026 (“På · blindveier kan velges”), and every road is offered at every junction, so the whole trip has 24 choices instead of 17. Saved settings now carry a version (v:2 in sofiatur.innstillinger): a Blindveier value saved before this change does not count, because it may only have been saved when another setting was changed. Music and choosing ahead are kept. Someone who turns Blindveier off gets it back after a reload.
Bunnpris at the T-junction. Olav Duuns veg meets Odd Husbys veg 30 m from the shop, and the mapped car park starts 5 m from that junction. A driveway now follows the mapped footway from the junction into the car park (map_fixes.add_bunnpris_parking), so a car coming down Olav Duuns veg has the car park and the shop straight ahead, and “Rett frem: Bunnpris” is a choice there. Parking places (KIWI, Dalgård ishall, Rema 1000 and Bunnpris) are stops: the car waits for a choice there even where a road leads on, as it must at Bunnpris, which now has two ways in.
Tests: verify-game.mjs (start button, no arrow, focus loss and P/Esc do not stop the car, first junction asks), verify-settings.mjs (defaults, migration of old saved values), verify-dead-ends.mjs and verify-roundabouts.mjs (explicitly with Blindveier off, and the default checked), verify-queued-choices.mjs (Blindveier off), verify-stavset.mjs (the T-junction and the driveway).
Why. A junction was only grey road strips meeting, although the OSM extract has what makes a Norwegian street recognisable. add-street-details.py reads it from byasen.osm and adds one key, street, to dist/map.json. dist/street-details.js draws it. Only what lies in the map region and beside a drawn road is kept. Sources, tags and what is approximate: docs/street-details.md.
On the road. 63 zebra crossings are white stripes (0.5 m with 0.5 m gaps) across the carriageway, with a blue gangfelt sign on each side; two crossings at signals have two lines instead. Every entrance to the eight roundabouts and 11 mapped give-way points have a row of white haitenner across the lane, and a vikeplikt sign; the roundabout arms are not in OSM but a Norwegian roundabout is always entered giving way. The Palermo signals have stop lines. 23 speed tables and 7 humps are gentle raised bands with white triangles on the ramps (the car does not bump; a crossing on a table has its stripes on it). The 15 turning circles at the end of cul-de-sacs are round asphalt areas, radius 8.5 m or less where a house is close, level with the road end. Traffic islands are raised 12 cm with a light kerb and grass or paving; where OSM's island lies over the road centre line the car follows (the arms at Byåsveien / Arnt Smistads veg), it is drawn as a flush hatched island instead, and every island is cut through where a zebra crossing runs over it. Each roundabout also gets a kerbed grass island in the middle.
Beside the road. 99 sidewalks (light paving, 2.2 m; 44 of them untagged footways beside a road) and 123 gang- og sykkelvei (asphalt, 3 m), 27 km in all, lie on the ground with a small lift. They are pushed out to at least 0.4 m from the road's kerb band and cut where they run over a carriageway. 118 street lamps (where OSM has them) are slim grey poles, 7.6 m, with an arm over the road and a lamp head.
Rebuild order: fetch-osm.py, prepare-map.py, add-lakes.py, add-dalgard.py, add-stavset.py, then add-street-details.py (idempotent: it replaces street). world.js has one call after addStavsetDetails that draws it and keeps trees off the paths and the roundabout islands (indexStreetDetails); everything on a road stands on roadTop, everything beside it on height.
Performance, same headless Chromium: 1,689,112 -> 1,703,435 triangles (+0.85 %; the module draws 20,600, about 1.2 %), and building the world takes 3.4-3.8 s before and after. Drawing the details takes 0.12-0.16 s of that. The trees are planted by a seeded loop that draws random numbers only for trees that pass clear(), so keeping them off the paths shifts every tree after them: the forest is as dense as before but grows differently.
Tests: node verify-street-details.mjs checks the count of each feature, that every zebra stripe lies on a drawn road, on the asphalt and 1 m from the next, that islands and turning circles are where OSM puts them (turning circles at road ends, raised islands 1.2 m from any centre line), that lamps and paths stay clear of the carriageway (0.2 m beyond the kerb band), that no tree can grow in a path (the same rule as clear()), and the triangle and time budget.
Every junction of the main trip and of the detour to Stavset was compared with the Esri aerial photo (for viewing only, not in the repository) and with Statens vegvesen's road database (NVDB). The result, junction by junction, is in docs/junction-audit.md. The street network itself agreed with reality: every public street is a road of the game, none is missing or invented, the one-way pairs (KIWI, Dalgårdvegen, the roundabouts) are right. What differed was the shape and the size of things, and that is now data:
width in map.json, from road_widths.py (applied by map_fixes.apply_road_widths in prepare-map.py), and roadWidth() in dist/transit-geometry.js uses it when it is there, the class defaults otherwise. Herlofsons veg is 4.0 m and Per Sivles veg 3.5 (gravel), Uglavegen 6.0, Arnt Smistads veg 6.0, Selsbakkvegen 7.0, Byåsveien 7.9-9.0, and the carriageways beside a splitter island 4.0-4.5 m (one lane), which makes the islands at the KIWI, Stavset, Lysverkvegen and Kystadlia roundabouts and at the Dalgårdvegen junction show instead of disappearing under two 6.5 m roads. The aerial photo cannot give a carriageway width (it shows sidewalk and verge as well, without kerb contrast), so the values are NVDB's "Vegbredde" (estimated by the road owner for municipal roads, computed from the road area for Byåsveien) and are attributed in map.json (source.roadWidth); the aerial was used to check them.map_fixes.ROUNDABOUT_ISLANDS holds the diameter of the grass island measured on the aerial for eight rings, and fit_roundabouts (called at the start of prepare-map.py) pulls the ring nodes in so the drawn island is that size. Exit order, arrows and choices are unchanged.Not touched, for the road-surface and street-detail work: saw-tooth outer edges and flat arm ends on the roundabouts, missing splitter islands and mountable aprons, zebra crossings (all four arms of the big roundabout, Palermo, 35336635, 1974499038). Vognhallvegen looks connected to Arnt Smistads veg on the aerial, but the link is a cycleway in OSM and Gang- og sykkelveg in NVDB, so the game rightly leaves it a dead end.
Rebuild order: prepare-map.py (now also applies the widths), add-lakes.py, add-dalgard.py, add-stavset.py, add-street-details.py. Test: node verify-junction-audit.mjs (widths of every road of the trips, the island of each measured roundabout); all other verify-*.mjs still pass. verify-road-height.mjs limited the Kystadlia ring to 7.0 m (NVDB says 7.8): wider, the ring's surface overlaps the steep splitter approach and the car sits 11 cm into it.
Roads used to be straight 8 m strips laid on the terrain, each corner at terrain height. They banked with the hillside, kinked at every grid cell border, left wedge gaps at bends and crossed each other at junctions. Now dist/road-geometry.js computes one plan and one profile for every road, and both the drawn road (dist/road-surface.js) and the car (world.roadLine, used by roadPoints in game.js) follow it, so the car stays on the drawn road.
Plan. Every free OSM vertex becomes a circular arc (fillet(prev,vertex,next,type), a pure function: secondary/tertiary 30/20 m, residential/unclassified 12 m, service 6 m; the tangent length is at most 45 % of the shorter segment, so arcs never touch; a bend that would push the road further into a house than the sharp corner does gets a tighter radius). Fixed points never move: every edge end (junctions, road ends, parking places) and every roundabout ring vertex. Rings are closed Catmull-Rom curves through their vertices. Roads are dense centre lines with samples at most 2 m apart, 1 m on tight bends.
Profile. Along each road (roads that carry straight on through a node are one chain) the level is a smoothing spline of the terrain under the centre line, lifted a little where the ground rises on the uphill side, with a grade limit of 26 % (public roads) to 32 % (service) on the 1.45 times exaggerated terrain, since the terrain itself reaches 66 % on the roads. All roads at a junction share one level at the node; a side road starts along the through road's plane. A bridge deck is a straight ramp between its ends.
Surface. Continuous ribbons with mitre joins (carriageway level across, kerb band 0.7 m each side, lying 9 cm lower and wider underneath). At every node with two or more arms that do not simply carry on there is a junction patch: each arm is a strip, neighbours are joined by a curb return (radius 8 m secondary/tertiary, 5 m residential, 3 m service, less where the arms are short), straight edges where roads carry on, a mitre on the outside of a bend. Ribbons start at the patch mouth (setback) and 3 cm inside it. Patch heights follow the plane through the node and the arms' mouth levels. Kerb bands follow the patch outline, so a road's kerb never runs over another carriageway. Roundabouts get a grass island at the level of the inner kerb. Centre-line dashes follow the smoothed roads. Where two neighbouring arms leave at levels the short curb return between them cannot reach (a step face steeper than 45 degrees), the corner takes the mean of the two levels and each ribbon tilts into it over its first 4 m instead; the strip under each arm's fan lies 45 cm under the fan at the node, so it cannot show through where the fans dip.
Ground. createRoadSurface(roads,height,data) lowers the 8 m ground grid where it would poke through the road (each sample on the carriageway must have the ground 16 cm below, one pass of least-squares pulls on the three grid vertices) and draws verge strips from the kerb edge down to the ground where the road stands above it (batter 0.6). world.height is terrain plus that lowering, heightAt is still the highest drawn road at (x,z) or null. Each ground grid vertex is now looked up once and bucket uses numeric keys, which pays for most of the road work in build time. Within 24 m of a junction node the ground beside an arm (up to 5 m beyond its kerb, between it and its neighbour arm when they are 8 to 100 degrees apart) is kept just below that arm's road, so a hillside does not close over the wedge between two roads. Verge strips stop short of any other road (and of the wedge between two arms where they would cross it), and are left out along a bridge; the ground under a bridge deck is never lowered, so the valley stays. surface.groundTop(x,z) is the ground as it is seen (the ground mesh, or the verge strip over it), and street-details.js puts sidewalks, gang- og sykkelvei, signs and lamps on it (markings stay on roadTop). Junction patches have no setback on a bridge arm: they stop at the abutment. heightAt(x,z,near) takes an optional level and then prefers the surface at that level, so a road under a deck keeps its own height.
Numbers (measured on the drawn surface unless noted; before, with metrics.mjs, in brackets):
metrics.mjs: median 0.0 %, 99th percentile 0.9 %, none over 6 % (median 7.6 %, 58 % of quads over 6 %, max 100 %). On the drawn surface sampled either side of the centre line 99.7 % of samples are at most 3 %, 99th percentile 0.8 % (the rest are places where two roads overlap).createRoadSurface 700 ms then, 850 ms now in Node). The clip of verges against other roads, the wedge rule and the visible-ground index are what was added; primitives are typed arrays and the triangles sit in a compressed 8 m cell index.Limits. A level ribbon cannot meet a tilted patch without a step; where that step would be a wall (over 45 degrees) the ribbon tilts into it instead, and the steep faces that remain (35 to 45 degrees, and a few walls at corners with very short setbacks) are drawn a little lighter. Acute junctions (under 35°, six of them) and the paired one-way lanes at roundabouts overlap: the higher surface wins, and surface.edgeLine puts the car on the drawn surface (checked every half metre, the line rises before a step). Seen from a chase camera 30 m down a 25 % slope, a side road at the top of it is edge-on (the camera is at its level) and shows as a thin line even though the ground beside it lies below it; that is perspective, not ground. Free driving on a verge uses terrain plus 0.39 m, not the verge strip. dist/street-details.js still uses streetSegments(data.roads) for its centre lines if world.js passes none; roadSurface.geometry.roadSegments() gives the smoothed ones (world.js does).
Tests: verify-road-geometry.mjs asserts the numbers above (fillet purity and tangency, fixed points, edge lines, cross slope with the throats counted apart, kinks, coverage, patches, ground, bridge deck and round rings, ground in the junction wedges, paths on the visible ground, no verge over a carriageway, bridges: valley untouched and no patch or verge on a deck); verify-road-height.mjs now drives every road on the smoothed centre line. Rebuild nothing: the map is unchanged.
Arrows. Coming up Byåsveien to the Munkvoll roundabout before the kindergarten, Bøckmans veg leaves 47° to the right and Byåsveien 36° to the left. Both were inside the 55° "straight on" cone, so both had a straight arrow. Now, where two or more roads at a junction or roundabout read as straight on, only the straightest one is; the others turn to their own side (straightest in game.js). Bøckmans veg is Høyre and listed first. Seven places change, among them Odd Husbys veg at the Stavset roundabout (now Høyre). verify-roundabouts.mjs checks Munkvoll.
All roundabouts. A roundabout's arrows no longer depend on the car's heading as it reaches the circle. Lanes at splitter islands curve in, which turned every arrow a little. Now each exit is measured on the map: the bearing from the circle's centre to the road in, 35 m before the circle, and to the exit road, 35 m after it (roundaboutLabels in game.js). Opposite the road in, within 45°, is straight on, and only the straighter of two such exits. A quarter turn round the circle is right and three quarters is left. Only the exit back to the road in is Snu. All 30 entrances of the eight roundabouts were checked against aerial photos. One was wrong: coming down Munkvollvegen into the roundabout on Byåsveien by Midelfarts veg, Byåsveien straight ahead showed as Høyre and Midelfarts veg as Rett frem. It is now right, straight on, left, left. Where two arrows point to the same side, the gentler turn sits higher on the screen and the sharper lower, at roundabouts and junctions alike. Before, the right-hand arrows at roundabouts and the left-hand arrows at junctions were the other way round. verify-roundabouts.mjs checks every entrance, with the car turned 25° either way as well.
KIWI chargers. The two chargers at KIWI Dalgård stood in Drivhusvegen, the way in to the car park. They now stand at the head of the parking bays on the car park's south-west edge, 8 m from the road (KIWI_CHARGERS in building-details.js); verify-road-geometry.mjs checks that they are off every drawn road.
No minimap. The trip map in the bottom right corner (På vei til barnehagen) is gone, and with it the map drawing in game.js.
Dalgårdbrua. Byåsveien north of the Kystadlia roundabout crosses the Dalgård valley on a 192 m bridge (OSM bridge=yes); the game used to take the road 16 m down into the valley. prepare-map.py now marks every road bridge in OSM (Kystadbrua and Dalgårdbrua), the road geometry lays the deck as a straight ramp between its ends, and addBridges in dist/stavset.js draws a concrete deck, edge beams and railings along the smoothed centre line, with piers every 30 m where the deck stands more than 3 m above the ground (five under Dalgårdbrua, up to 16.7 m high).
Rewards 3 and 4. The third trip to the kindergarten unlocks a rainbow car: a new swatch among the colours whose paint runs slowly through all colours (about 11 s a round). The fourth trip turns the car into a running cat (dist/cat-model.js, drawn with three.js shapes): it gallops at the car's speed, bobs, streams its tail behind, and when it stands it breathes and flicks its tail and ears. The cat has the car's colour, so the rainbow car gives a rainbow cat. A switch on the start screen changes between cat and car. Both rewards are picked when they are unlocked and kept after a reload; storage cannot unlock them early. The start screen hints at a surprise until all four are unlocked. verify-rewards.mjs covers both.
Debug keys (to test the unlocks; keyboard only, not while typing in a field): X counts one more trip to the kindergarten, Z one fewer, never below 0. Counting up picks the new reward as an arrival does (the rainbow car at 3, the cat at 4); counting down locks what the count no longer allows. A toast shows the count, and the count is saved like a real one. verify-rewards.mjs covers them.
Why. The car followed each edge's centre line and switched to the next edge at the junction node, so at every turn it pivoted on the spot (main trip: 105 frames under 5 m radius, 69 under 2 m, e.g. 105 degrees in one frame at (713,-50); lateral acceleration up to 209 m/s2, since the speed only knew the roundabout cap and the braking for the next stop) and drove on the centre line.
Lane. dist/driving-line.js (pure geometry, no three.js) builds the car's line from world.roadLine. On a two-way road the car drives a quarter of the carriageway width (roadWidth) to the right of the centre line, on one-way roads, roundabout rings and single-lane service roads (under 4.6 m) it stays in the middle; at home, the gate and the parking places the lane eases to the centre over 9 m so the car stops on the node. A lane is smoothed (Gaussian, 2.2 m) and along the middle of a road its bends open out by up to 0.8 m (less on narrow roads: 0.5 m stays to the kerb), but not within reach of a junction connector.
Junctions. At every node the car leaves the lane it came along some metres before the node and joins the next lane some metres after it, on a cubic Bezier chosen by search: the shortest setback (2 to 22 m, unequal ends allowed) that gives the radius the turn deserves (T0 / tan(phi/2) with T0 = 5 m + half the wider road, 5.5 to 35 m; sharp turns over 110 degrees 4 m) without leaving the drawn road (checked with world.surfaceTop, every 5 cm on the best few). The car waits at one stop line per arm (stopX), the earliest connector start of the roads that can follow, at least 2.5 m before the node; a queued or automatic junction is driven through on the same arc. U-turns on a reversal are three-point (or, from a stand, two-point) turns: arcs of 3.6 to 5 m radius with cusps where the car reverses (line.reversing, the wheels turn the other way, the body direction stays continuous). Parking places, home and the gate still end at their node.
Speed. The limit along a line is sqrt(2.5 m/s2 / curvature) (curvature over a 3 m chord), plus the roundabout's 45 km/h; planAhead works it backwards through every line the car will drive without asking (queued roads, automatic junctions, roundabout plans) with braking at 4.5 m/s2 to where the car must slow (a stop, a crawl of 2.4 m/s at automatic junctions with blindveier on, parking places, the goal). The car speeds up by 7 m/s2 and brakes by at most 8. The lookahead now treats parking places as stops, as showDecision does.
Numbers (recorded frames of the trips in the game state machine on the real drawn road; radius from three frames 1.5 m apart along the path, lateral acceleration v2/R; before in brackets):
heightAt is not null) in every frame of both trips and never more than 5 cm into it (verify-road-height.mjs, every edge alone).Tests. node verify-driving-line.mjs drives both trips and asserts radius (5 m; 3.5 m in standing starts and sharp turns; the road's own bends: 3 m or the road's radius plus half a metre), lateral acceleration (3 m/s2), no jump in position or heading, the drawn road in every frame, the side of the road (independent of driving-line.js), and that the lines of all 2 118 road pairs join up. All other tests get createDrivingLines in their env (the module is imported by game.js, and the tests inject imports). Changed expectations, deliberately: verify-game.mjs peak speed 195 to 100 km/h (curvature, not a 200 km/h cap, limits the car) and first stop over 5 m instead of 10 (the stop line is before the node); verify-queued-choices.mjs queued junctions are crossed at 14 to 64 km/h (arc speed) instead of at least 20; verify-dead-ends.mjs the automatic turn is taken at 19 km/h (the arc) instead of at least 150 km/h.
Known limits. 71 of 2 118 road pairs (short links of 2 to 5 m between junctions, acute Y-junctions) cannot be driven at 5 m radius on the drawn surface. world.surfaceTop(x,z,near) (one line in world.js) exposes heightAt; without it (mocks) the lines still work but do not avoid notches in the surface.
Why. West of the measured terrain (x = −200) and north of it (z = −680) the heights used to continue from the edge of the measured grid. That left both lakes in flat terraces with walls and a saw-tooth shore: the ground dropped to the water level inside the lake outline, a step that the 8 m ground grid cut into teeth. Seen from Herlofsons veg and Vetle Vislies veg, the east shore of Lianvannet was a row of green cliffs. The map also stopped 60 m short of the water: Vetle Vislies veg ended at x = −180.
Ground. add-lakes.py now takes the heights beyond the measured grid from Mapzen terrain tiles (OpenTopoData, about 30 m resolution, whole metres), as add-stavset.py does for the rows south to Stavset, because Kartverket's height service still did not answer. At the edge, the difference to the measured grid is carried over and fades out within 160 m. The water level comes from EU-DEM (OpenTopoData eudem25m), which is flat on lakes: Lianvannet is at 211.9 m everywhere in it. Mapzen is not flat on water (219 m in the north of Lianvannet, 214 m in its south lobe), and a level from it left pits beside the water. The level is no higher than the low rim of the shore (the 5th percentile of the ground along the outline). That rim decides for Kyvannet, 183.5 m, where EU-DEM's 187.5 m lies above the measured shore.
Shore. dist/lakes.js (used by world.js, tested on its own) lets the ground meet the water along the outline. Over a bank four times as wide as the ground there stands above the water (16 to 64 m), the ground runs down to the water level. Under the water the bottom falls away gently. Where the ground beside a lake lies below the water (the outlet), it dips away from the shore instead. No 2 m step on the banks is steeper than 55 %.
Roads. fetch-osm.py fetches a third box, the east shore of Lianvannet (10.3195,63.3978,10.3262,63.4025). map_fixes.WEST keeps the roads, houses and woods in it (51 more buildings). Vetle Vislies veg now runs on from Herlofsons veg down to the turning circle by the water (OSM node 201496265, map_fixes.LAKESIDE, 10 m from the lake, 1.1 m above it). Per Sivles veg (19799303) leads on from there along the slope, and Arne Garborgs veg runs on to its end. The turning circle is a place to drive to, like the car parks: it is offered with blindveier off too, the car stops there, and the camera turns from the road to the water.
The duck. dist/duck.js is a mallard drake 12.7 m long and 7.3 m tall, two and a half times the car. It swims round a 13 m circle in open water 54 m from the turning circle, bobbing, nodding, wagging its tail and now and then dipping its bill, in a ring of ripples. It rises out of the lake the first time the car comes within 140 m ("Se! En kjempeand i Lianvannet!"), and stays for the rest of the visit.
Tests. verify-lakes.mjs checks the terrain beyond the measured grid, the lake levels against the ground round them, the shore (the ground is at the water on the outline, under it 3 m out, above it 3 m in, and no walls on the banks), the road to the water and the duck (size, a circle on open water). verify-dead-ends.mjs drives Vetle Vislies veg to the lake with blindveier off. Its right-or-blindvei example, and the one in verify-settings.mjs, is now Kystadhaugen, since Vetle Vislies veg leads on.
Speed. The car accelerates at 20 m/s², brakes ahead of a turn or a stop at 12 m/s² and at 16 m/s² at most (7, 4.5 and 8 before), so it reaches 200 km/h again on the longest stretches of the trip: the 364 m and 297 m ones on the main trip, and on the detour to Stavset. Turns keep their gentle arcs and speeds (lateral acceleration 2.5 m/s², at most 2.6 measured). The main trip takes 202 s instead of 263 s (148 s with the old car that pivoted in the junctions). verify-game.mjs again expects 200 km/h on the trip. verify-driving-line.mjs now measures the side of the road every metre along the road instead of every frame: frames crowd where the car is slow, in the bends that a lane may cut, and the faster car made that tell.
Debug keys. X and Z count the trips to the kindergarten up and down, to test the unlocks (see Rewards 3 and 4 above).
KIWI cat. With the secret KIWI skin the cat, KIWI green like the car, wears the KIWI logo on both flanks: a strip bent round its body, reading from tail to head on the right and from head to tail on the left, as on the car's doors (cat-model.js takes the skin logos, world.js shows the chosen one). verify-rewards.mjs checks it.
Bunnpris. The driveway from the T-junction where Olav Duuns veg meets Odd Husbys veg into the Bunnpris car park did not exist in reality (the user, 30 September 2026), so it is gone: the car park is reached from Granlivegen, as mapped (map_fixes.add_bunnpris_parking, verify-stavset.mjs).
Rewards in a new order (the user, 30 September 2026): 1 trip the colour picker, 2 the running cat, 3 the rainbow trail, 4 the rainbow colour. The rainbow colour now runs through all colours in a little over three seconds (about eleven before). The debug keys follow the same order. verify-rewards.mjs checks each step.
Fullscreen button (30 September 2026): a button beside sound and settings puts the game in fullscreen and back (the Fullscreen API, with Safari's webkit names). It shows only where the browser allows fullscreen, not on an iPhone.
Why. The world was one pale lime green under flat white-blue light: lawn, meadow and forest looked the same, shadows were weak, the sky was a plain colour and the horizon empty, although Byåsen looks out over Trondheim, the fjord and the hills of Bymarka. Everything that decides how the world is lit and coloured is now in dist/look.js (pure functions, no WebGL needed to test them); world.js only calls into it, and street-details.js takes its road, kerb and path colours from it. The code that draws buildings and trees was left alone; buildings and trees change colour only because the light does.
Light. A warm sun (#ffd9a6) stands 37 degrees up in the west-south-west (bearing 238, afternoon light from behind the car on the main trip, which drives east), with a cool sky light from above (#b0c9ea) and a warm olive bounce from the ground, so lit grass is warm and shadows come out soft and blue. Output is sRGB with ACES filmic tone mapping at exposure 1.12 (1.28 before, with a sun 55 degrees up from the north-west). The shadow map is 2048 (1024) over 108 m (90 m) and follows the car 18 m ahead of it in whole texels of the light's own grid, so shadow edges no longer crawl when the car moves. The bias was found by trial (-0.0012, normal bias 0.08): the kerb band lies 9 cm under the asphalt and a smaller bias cast a sawtooth shadow of that step on it. Sun, sky light and pixel-ratio cap are constants at the top of look.js (SUN, HEMI, RENDER).
Sky and horizon. The fog (110 to 560 m, it was 165 to 510) is exactly the colour of the sky at the horizon and the dome keeps that colour from 1.5 degrees up to straight down, all the way round, so the far ground melts into the sky without a seam; the chunks are drawn to 680 m, so nothing pops. From the chase camera only the lowest ten degrees or so of the sky are on the screen, so the sky is built for that band: a hazy horizon that turns blue above, a creamy glow towards the sun, 16 low-poly cumulus clouds with flat bellies that drift round the camera (one turn in 50 minutes), three layers of hills in aerial perspective (Bymarka and the ridge towards Gråkallen in the west-north-west, the hills east of Trondheim, the lower land to the south; the ridge is at most 6 degrees high, about twice the real angle) and a pale blue ribbon of fjord low on the horizon towards the north and east. All of it is 11 936 vertex-coloured triangles in six meshes, drawn behind the world (depth-tested, so the ground covers them), riding with the camera; nothing is drawn as a full-screen pass. The software rasteriser draws no sky.
Ground. The 8 m ground grid is painted per vertex (createGround) instead of four greens in 40 m squares: lawn where people live (the share of ground within 48 m that lies under buildings), rough meadow and dark forest floor where they do not (and inside OSM's wood areas), a few dry patches, earth and rock from about 30 degrees of slope, noise at three scales (95, 27 and 9.5 m) so nothing repeats, and ground darkened and cooled within 9 m of a building wall (a cheap ambient occlusion). Verge strips take their colour from the ground beside them. Lake beds are coloured by depth (sand, teal, deep blue) with sand and reeds along the shore, under one see-through water surface per lake that mixes in the sky at a low angle (Fresnel) and glitters in the sun on small moving ripples (a few lines added to a Phong material's shader; one uniform, time, is set per frame).
Roads. Asphalt is a touch darker and bluer (#5f676c) with patches about 11 m wide and a fine speckle, gravel roads are warmer, the kerb band a clean light concrete grey (#b3b6b2, 3.5 times as bright as the asphalt, so kerbs read from the chase camera), sidewalks and cycleways have their own greys, centre lines stay yellow. Every road primitive is painted per corner (createRoadPainter).
Numbers (Node, software renderer, stand-in DOM, node verify-look.mjs builds the real world; nine alternating builds each in one sandbox that other jobs share, so the noise is large; before in brackets): world build 4.05 s (3.82 s) median wall time, +5.8 %, best of nine 3.45 s against 3.61 s; CPU time +1.8 %. createGround takes 60 to 100 ms of that and the sky 15 ms; the rest is the per-corner road colours. Part of the cost is paid back in lakes.js: shoreDistance, called for every ground and road sample near a lake, skips an edge whose bounding box is farther than the best distance so far (250 ms of the build before; the result is identical on 280 000 random points). Triangles 1 910 700 (1 898 764), +0.63 %, all of it the sky (the browser count of shoot.mjs goes from 1 859 907 to 1 871 843); the ground has the same two triangles per cell as before. Rendering: the world material is still Lambert with vertex colours, the extra per-pixel work is the water's few lines of shader on the lake pixels; the shadow map is four times the pixels of the old one, but the geometry drawn into it is the same. No post-processing.
Also. The software renderer had no getPixelRatio, so createWorld threw without WebGL; it has one now (software-renderer.js).
Tests. node verify-look.mjs checks the renderer settings, the lights (warm sun, cool sky light, snapped shadow box, no allocation in follow), fog = horizon colour at every bearing, the sky (triangles, layers, draw order, unfogged, clouds drift, follows the camera), the ground on the real map (hundreds of colours, lawn varies, forest floor 35 % of lawn brightness, darker at buildings, greyer on steep ground, lake bed by depth, sample), the road (darker, bluer, kerb contrast, verge = ground, gravel warm), the water shader and, by building the world, that all of it is wired into world.js. Not done: cloud shadows, a real tree-floor shadow (trees are placed elsewhere), any change to buildings or trees; the light is tuned by eye on SwiftShader renders, not on an iPad.
Why. The eight roundabouts looked crooked (skjeive). Every ring in the OSM data is a circle to within 0.4 m and the lanes leave it where the real roads do (checked on the aerial photo), so the fault was in how the road surface was built round a circle: the ring was a ribbon level across, round a slope (a twisted strip that shaded in radial spokes, up to 90° between neighbouring triangles, the centre line up to 31 cm off a plane), a straight junction patch at every arm bulged and kinked on a circle of 7 to 16 m, the lanes ended in flat cuts, the island rim was a 12 to 17-gon, and the rings and splitter lanes had centre-line dashes. Two rings (Stabells veg, Stavset) were also 0.30 and 0.38 m out of round. Analysis, table per roundabout and numbers: docs/roundabouts.md.
Now. map_fixes.fit_roundabouts fits each ring as a least-squares circle and puts every ring node on it (angles kept, so the lanes, exit order and arrows stay); the street details take the same circle. dist/road-geometry.js: no junction patch on a ring (it is one closed ribbon, each lane ends square at its outer edge), and each ring lies in one plane. dist/road-surface.js (mouth()): each lane tilts into that plane over its last 4 m, rows of quads close the gap to the circle and a curb return (an arc tangent to the lane's edge and the ring's outer circle, at most 5 m, less beside a narrow splitter island) flares every entry and exit; the ring's kerb band and the island rim follow the plane; no dashes on rings or one-way lanes. dist/street-details.js: the island rim has a point every 0.8 m; a one-way entry is given way across its whole width. dist/game.js: the arrows' bearings are measured from the circle's least-squares centre (no label changes: all 30 entrances compared before and after). One shared-code fix on the way: a ribbon's kerb under-layer is split on the same diagonal as its asphalt and follows its tilt, so a tilted lane no longer shows its kerb through the asphalt. The top-down recipe in the toolkit README ([x-0.01, H, z+0.01, x, 0, z]) gives a picture turned 45°; [x, H, z+0.01, x, 0, z] is north up.
Numbers. Ring asphalt in its plane to 0.00 mm (the centre line was 6 to 31 cm off at worst), triangle normals within 0 degrees (was 8 to 14 degrees rms, 27 to 90 worst), nodes on one circle to 1 cm (was 30 and 38 cm at Stabells veg and Stavset), island centre on ring centre to 1 cm (was up to 10 cm), island diameters unchanged (within 0.05 m of the measured ones). Triangles 1,859,907 -> 1,863,779 (+0.21 %); world build time 3.3 to 5.0 s (mean 3.8 s) before and 2.9 to 3.9 s (mean 3.4 s) after in headless Chromium, three loads each, no measurable change (noise about 5 %). Rebuild order unchanged (prepare-map.py, add-lakes.py, add-dalgard.py, add-stavset.py, add-street-details.py).
Tests: verify-road-geometry.mjs item 14 (rings round, flat, island rim in the plane, no kerb above the asphalt at the mouths, no holes, no spokes; the cross-slope test leaves the rings out, since they tilt across) and verify-junction-audit.mjs (nodes on one circle, radius, island centre); all 21 verify-*.mjs pass, verify-roundabouts.mjs unchanged.
Why. "There is an underpass/overpass missing at Rema 1000" (the user). add-street-details.py skipped every OSM path with bridge or tunnel, so the gang- og sykkelvei simply stopped at the road. add-footbridges.py reads them from byasen.osm and adds one key, footbridges, to dist/map.json; dist/footbridges.js draws it, from one line in world.js. Sources, what is OSM and what is an estimate, and what is left out and why: docs/footbridges.md.
Built (7 footbridges, 5 underpasses; before/after pictures, each next to the aerial photograph, were made for this change and are not committed): the footbridge over Byåsveien at Rema 1000 Stavset (OSM 191325644, with the mapped steps 1319298906) and the cycleway tunnel under Byåsveien at the Stavset roundabout (191325643); the cycle bridges beside Kystadbrua (101187272) and Dalgårdbrua (35086814); four timber footbridges (289753479, 289753482, 289753483, 1410709998); four more underpasses (112895868 at Dalgård, 1152629239 on Odd Husbys veg, 160676278, 24575918). The one at 23087067 is not built: its path runs 2.5 to 3.5 m beside a road for 60 m, a trench would cut the road, and the path ends at the road as before.
Footbridges. The heights are measured on what the game draws: the deck's underside is 4.7 m over the highest drawn road surface under it (surface.heightAt), flat over the road, then straight 1:10 ramps down to the paths on the ground, on along the paths they join where the OSM way is too short (at Rema: 130 m, 15 m flat, ramps of 66 and 49 m; the deck is 4.34 m wide as in Statens vegvesen's bridge register). Concrete slab, asphalt, a steel railing, closed concrete sides where the ramp is under 1.6 m high and piers (heavy ones just outside the road) above that; the mapped steps are a flight of 16 cm risers from the deck down to the path. Decks beside a road bridge are level with it and clear of its edge.
Underpasses. A concrete culvert (4.4 m wide, 2.8 m clear, black inside with ceiling lamps) under the road with its roof under the ground as it is seen; from each mouth the path goes down a trench with concrete walls, a coping and a railing, widening a little at the mouth (the wing walls), and rises at 1:10 (1:8.3 at most) to the ground along the path's own line. The trench is cut out of the ground mesh exactly (cutGround: the two triangles of each 8 m cell are found in the bucket by their corners, taken out, and put back minus the trench). A blue sign for the path stands on each rim, facing the road, so a car passing over can see where the dip is. add-footbridges.py cuts the paths that the dips replace out of street.paths, and footbridges.js draws the path on the ground beyond, kept off the carriageways.
Rebuild order: fetch-osm.py, prepare-map.py, add-lakes.py, add-dalgard.py, add-stavset.py, add-street-details.py, then add-footbridges.py (it replaces footbridges and cuts street.paths once; run it again after every run of add-street-details.py). The holes rely on world.js building the ground as it does (8 m lattice, two triangles a cell); if that changes, the console says so and verify-footbridges.mjs is the place to adapt.
Performance, same headless Chromium: 1,859,907 -> 1,889,347 triangles (+1.58 %; the module draws about 27,800 and the cut ground adds 2,200 net) and building the world takes 2.8-4.7 s after and 3.4-5.2 s before (noise from a shared machine is larger than the module's cost). The module's plan and drawing take about 0.1 s of that.
Tests: node verify-footbridges.mjs checks that the two crossings at Rema exist where OSM puts them, that every deck is at least 4.2 m over the drawn road under it (the plan, and the quads actually drawn: 4.70 m at Rema) and over the ground, that ramps and the floors of the underpasses are no steeper than 1:8 and steps are mapped ways, that decks end at the level of the path, that the culverts lie under the roads with their roofs under the ground, that no path (in street.paths or drawn here) lies on a carriageway or in a trench, that the holes are cut and the ground is otherwise unchanged, that nothing stands within 1 m of a house and the trees keep off, and the triangle and time budget.
What changed. Every ordinary building is now drawn by dist/houses.js (the loop in world.js only calls it), with its colours from dist/house-looks.js and eaves from dist/building-roofs.js. All of it is merged vertex-coloured geometry in the existing buckets (no mesh per house), seeded by the OSM id so a building looks the same on every run. Walls: concrete-grey plinth, board cladding as alternating tone stripes (vertical or horizontal, or plain render), white corner boards, terraces with a joint and a tone change per dwelling. Windows: white frame, glass fading from sky to dark (some with curtains or dark rooms), a mullion, sometimes a transom, and a sill. Doors: frame, leaf (red, green, blue, brown, black, white, ochre), step or stairs down to the ground, a small canopy on some; garage doors with ribs; a door for every dwelling in a terrace and for every stair of a block; windows in gables and in a tall plinth on a slope. Roofs: eaves overhang (0.5 m, gable ends 0.3 m, flat roofs a slab edge) following the footprint (offsetPolygon, falling back to no overhang when it would not be a clean outline), white fascia and barge boards, ridge cap, tile courses or sheet-metal seams, roof lights, chimneys (brick, plaster, concrete, black flue) in about half of the houses. About 30 % of houses get a garden-side veranda and about 60 % of blocks a row of balconies on the long wall away from the road, as procedural estimates unless a style record says otherwise (veranda:false, balconies:false). Palettes (house-looks.js) follow what the aerial photo and the road images show: white and cream, light and dark grey, dark and mid brown, falu red, ochre, a little beige, sage and blue-grey; black, grey, red and brown roofs; a garage takes its house's colours. Photo-matched records (building-details.js, junction-observations.js) keep their colours, and the old junction detail pass (addJunctionDetails) is gone: its sashes, doors and fascia are part of the general builder.
Level of detail follows the road, not the camera. The game is on rails, so each wall is detailed by the distance of the nearest drivable road in front of it: full detail within 26 m, frames and glass to 52 m, plain glass beyond; roofs get seams and roof lights within 30 m. Measured on the real map (verify-houses.mjs): 5 640 buildings, 1.19 M triangles for the houses.
Missing buildings (fetch-matrikkel.py, audit-buildings.py, add-buildings.py, building_lib.py; run after add-street-details.py, add-buildings.py last). The OSM extract stops at the edges of its boxes (west x < -113, east x > 2083 round the kindergarten, z > 720 east of x 1253) and prepare-map.py cuts along the Stavset and Lianvannet polygons: add-buildings.py --fetch downloads those strips again (OSM API, 30 September 2026) and adds 430 footprints (data/osm-extra-buildings.json, "add":"osm"). Matrikkelen, Kartverket's building register (WFS, CC BY 4.0), gives one point per registered building (data/matrikkel-bygningspunkt.json, 8 526 points); the 159 registered buildings without any OSM footprint near them (161 before the new OSM download) were looked at one by one on the aerial photo (viewing only) and 53 houses, garages and blocks that are plainly there are drawn as a typical rectangle of the registered type on the register's point, turned like the neighbours (data/building-additions.json, "add":"matrikkel", with bygningsnummer, type, status and note; the corners are not surveyed, 4 are building sites with a building permit (IG) and one has a temporary occupancy permit (MB)). Re-running add-buildings.py replaces what it added and changes nothing else; if dist/map.json is rebuilt by another script, run it again. It also puts mk = [registered type, dwellings] on 1 123 buildings where the register says more than OSM (terraces and flats: dwellings; building=yes; a different kind), and kindOf lets the register win: a row of houses mapped as house becomes a terrace with a door per dwelling, a garage tagged yes a garage, a block without building:levels gets its storeys from the flats counted (about 62 m² of flat per 0.78 of the floor plate).
Junction audit. audit-junction-buildings.py --write writes docs/junction-buildings.md: a table over all 217 junctions (37 on the main trip, 60 on the detour to Stavset, 120 others): footprints and registered buildings within 60 m, registered buildings without a footprint before and now (100 → 35 within 60 m of junctions; the rest is forest, building sites and sheds in shade), kind corrections, roofs judged by eye, what was done and the source, plus a table of the general findings. 403 houses within 34 m of the main trip and the Stavset detour were compared with the aerial photo, with the game's footprints, ridge lines and roof colours laid over it (56 views); 112 roofs that were clearly different got a record with the source aerial-esri-2026-09-30 (63 dark, 23 light grey, 15 red-orange, 11 brown, 2 shapes); the aerial was only looked at, nothing was traced or sampled. The block at Adolf Andreassens veg 2A/2B by the kindergarten has red-orange tiles in the photo, not the grey-brown that was recorded. Statens vegvesen road images at four places on the county roads confirmed that the houses are set back behind hedges and fences at Kystadlia and Dalgård, and gave two records at Palermo (a red timber house with a black tiled roof and a brown house, docs/building-references.md).
Budget and tests. Triangles 1 859 907 → 2 090 569 (+12.4 %, limit +15 %); cold world build, six alternating runs in SwiftShader, median 3.17 s → 3.22 s (+1.7 %, limit +10 %). node verify-houses.mjs (new) checks seeded looks, the palettes (white, dark, red and yellow houses all present, not mostly grey), the eaves geometry on gabled, hipped, skillion and flat roofs, the builder on the whole map (finite vertices, same triangles and vertices on every run, triangle budget, doors, windows, chimneys, verandas, balconies), that every added building is documented, overlaps no footprint and stands on no road, that every style record has a source, and that the audit table has a row for every junction. Before/after screenshots and six junction close-ups next to the aerial photo (viewing only, not in the repository): polish/B/ in the session scratchpad.
Not done / open. Wall colours and storeys of buildings without a photo match are still seeded estimates, as is everything on the back of a house; the aerial photo does not show wall colours. Roof colours by eye in shade (under trees) are uncertain. The 35 registered buildings without footprint near junctions (clearings in forest, building sites, small sheds) are not drawn. Web photos were not searched again beyond the listing photos of the earlier passes.
Why. Houses stood in empty lawns with no way in, no fence and no hedge, along roads that had nothing beside them but the same 10,000 round and cone trees thrown at random over the whole map. Three open sources say what is really there, and the game now draws it. Sources, tags, counts and what is approximate: docs/roadside.md.
Where it comes from. OpenStreetMap (byasen.osm: 233 fences, 70 hedges, 40 walls (31 of them noise barriers), 20 retaining walls, 2 guard rails, 400 service=driveway, 666 trees, 31 wood areas, benches, bins, masts, pylons), Statens vegvesen's road database NVDB (fetch-nvdb.py writes the committed nvdb-roadside.json: noise screens, guard rails, fences, retaining structures (390, 159 in the map) with their visible height and material, 241 trees (146 in the map) with species and height, shrub fields, benches and bins), and NIBIO's AR5 land-resource map (fetch-ar5.py writes ar5-skog.json: where there is forest and whether it is coniferous, deciduous or mixed, in cells of 5 m). add-roadside.py puts them in one key, roadside, of dist/map.json (618 barrier lines of 25.7 km, 762 driveways and paths of 45 km, 891 mapped trees, 253 pieces of furniture, 47 car parks). Where OSM and NVDB have the same noise barrier or guard rail the OSM line takes NVDB's height, material and colour.
At Gamle Oslovei, where Uglavegen comes down. The east side of the road has a 256 m timber noise screen painted red and 1.0 m high (NVDB Skjerm 574084340, summer 2015; OSM way 1368394242), a guard rail of steel pipes on timber posts (OSM 1368394244, NVDB 587807865) and, 100 m further north, an untreated 1.7 m screen (NVDB 574084345). Retaining walls of concrete and stone stand by the side roads (NVDB Støttekonstruksjon, five by the junction). All are drawn, with the visible height NVDB gives. The OSM lines agree with NVDB's to a median 0.18 m (noise) and 0.07 m (guard rail): OSM took them from NVDB, so this is one source, not two. Statens vegvesen's road images do not cover Gamle Oslovei (a municipal road), so the screen could not be checked against a photo.
What is drawn (dist/roadside.js, dist/trees.js):
fitPath of the street details, then the road surface's own heightAt), and leave a gap where a driveway or path comes through.InstancedMesh per species and 320 m chunk, pushed into the world's chunks so they follow its distance and shadow rules. 720 mapped trees where OSM and NVDB have them (species and height), 7,436 forest trees by AR5 type (spruce, birch, pine; thick within 35 m of a road or house, thin far off) and OSM wood areas, 776 scrub shrubs, 4,646 garden trees round the houses, 1,755 trees on the unbuilt strips by the roads and 2,445 shrubs by house walls (these last three are seeded fill, not data). No tree on a road, path, driveway, pitch, running track, playground, car park or in a building or water, and a crown reaches at most 0.4 m over the edge of a road, so the car never meets one. The old three random-tree loops of world.js are gone.Performance, headless Chromium (SwiftShader), alternating runs: the whole scene has 2,224,235 triangles with the instances counted (1,898,764 before, +17.1 %; the toolkit's tris() counts an instanced mesh once and now reads 1.71 million instead of 1.86 million), of which the roadside adds 128,000 merged and 499,000 instanced (the 297,500 of the old trees are gone). Building the world takes 2,803 ms of main-thread CPU time instead of 2,755 ms (+1.7 %, median of five; the minimum +7.7 %; the wall time varies with the other work on the machine); addRoadside itself takes 0.6 to 1.0 s, the old trees about 1 s. Draw calls per frame: 59 to 150 at the junction, 62 to 144 at Dalgård, 28 to 52 at Lianvannet (one per species and chunk); triangles per frame +28 %, +14 %, +33 %. The data adds 316 kB to map.json.
Rebuild order: fetch-osm.py, prepare-map.py, add-lakes.py, add-dalgard.py, add-stavset.py, add-street-details.py, then fetch-nvdb.py and fetch-ar5.py (they need network access and write the committed nvdb-roadside.json and ar5-skog.json) and add-roadside.py (idempotent: it replaces roadside; it takes the two files as optional arguments).
Tests: node verify-roadside.mjs checks the counts by kind, that the fences at Gamle Oslovei / Uglavegen are present (red timber noise barrier 1.0 m, the guard rail, the 1.7 m screen, retaining walls), that nothing is on a carriageway (heightAt at barrier samples every 0.5 m, ways, furniture, the corners of parked cars, every tree), in a building, in a path or on a pitch, that crowns stay clear of the road, that the same trees grow on every run, species and size variation, the triangle budget (at most +20 %, instances counted), the build time and that the software renderer draws instances. All 22 verify-*.mjs pass.
Open. Garden fences and hedges exist only where an OSM contributor drew them; most houses have none in the data and none are invented. AR5 and the OSM woods are years old, and wooded strips that AR5 calls built-up (the embankments along the tram, for example) only get the seeded roadside trees. NVDB has 693 lamps and 525 lamp posts that the street details do not draw (agent A's lights); the NVDB signs inside the map are five. Draw calls rise by 90 per frame; merging species per chunk would need a texture atlas.
Full rebuild order (1 October 2026, after the visual polish; with the Dalgård steps of 2 October): fetch-osm.py, prepare-map.py, add-lakes.py, add-dalgard.py, add-stavset.py, add-street-details.py, add-buildings.py, add-footbridges.py, add-roadside.py, add-schoolyard.py (needs shapely), add-dalgard-neighbourhood.py. add-lakes.py and add-dalgard.py take a saved Overpass answer as an optional argument; fetch-matrikkel.py, fetch-nvdb.py and fetch-ar5.py refresh the committed data files they read and need network access. After a rebuild, python3 audit-junction-buildings.py --write refreshes docs/junction-buildings.md.
Why (the user, with screenshots): nearly every house on a slope stood on a tall, blank grey base, and at the KIWI roundabout the noise screen ran down into a ditch that is not there in reality.
The ditch. A junction wedge keeps the ground between two arms just below their level, out to 24 m from the node. It ran 24 m along an arm even where the arm's road is shorter: the 4 m stub of Anders Wigens veg by the KIWI roundabout dug a 2.7 m pit beside General Bangs veg, right under the noise screen. The wedge now reaches no further than the arm's road plus 2 m (road-surface.js). That removed 274 such pits over the map, up to 7.6 m deep. Beyond that, the 40 m terrain is too coarse for the streets: 5 m beyond the kerb the ground lay more than a metre below the road along a quarter of all road metres (a bank and a ditch where the street is really level with its gardens). Every 8 m ground vertex near a road is now raised towards that road's level, CLEAR below it up to 4 m beyond the kerb, then falling 0.18 per metre (at most 3.5 m; not under or beside a bridge deck, not within 20 m of a lake). The lowering that keeps the ground under every road surface runs after it. Now 8.6 % of those samples are more than a metre below the road (was 25.6 %); the screens at the KIWI roundabout stand 0.5 to 0.9 m below the road instead of 1 to 3.6 m.
House bases. The floor stays at the ground on the uphill side. Below it, instead of one concrete face down to the lowest corner (median 1.6 m, a tenth over 4 m, up to 13 m with the 1.45 terrain exaggeration), houses.js draws what is built on Byåsen's slopes: a concrete plinth only 0.45 m high that follows the ground along each wall, above it a basement storey (sokkeletasje) clad like the house (two in three timber houses, with a trim board under the floor) or in light rendered concrete, with windows like the floor above where it is a full storey. It is at most one storey high (1.25 m under a garage, two storeys under a block); below that the ground is filled up to a terrace round the house: a 0.7 m shelf by the wall, then a grass bank at 1:1.6 down to the natural ground (coloured like the ground there), or a concrete retaining wall where a road is in the way. Doors and steps stand on that terrace. Walls are split only where the ground along them bends (12 cm from a straight line). House triangles 1 186 195 → 1 272 935 (+7 %).
Home (the user: "park at home again"): home is a parking place again. The car still drives out of the drive by itself at the start, but driving back it stops at home, says "Vi er hjemme igjen!" and after 2.5 s the start screen comes back, with the car colours once they are unlocked (the user: "bring up the car colour menu when parking at home").
Ludvig (the user: "park/visit Bøckmans veg 102, say 'Du besøker Ludvig'"): the four dwellings 102A-D (Kartverket's address register) stand just south of Bøckmans veg, near the kindergarten but off the main trip, with no driveway in OSM. map_fixes.add_ludvig_parking (run by prepare-map.py) adds a drawn 3 m driveway from Bøckmans veg at OSM node 8910717721 to a parking place between 102A-B and 102C-D; the road is offered as "Ludvig", and parking there says "Du besøker Ludvig!".
The running boy (the user: replace the rainbow trail): the reward for the third trip is now a little boy (dist/boy.js, about 1.15 m: fair hair, blue T-shirt with a yellow stripe, red shoes) who runs after the car along its own track six metres behind it, so he takes the same bends and stays on the road. When the car stops he catches up to its right rear corner, turns to it and waves. His switch on the start screen replaces the trail's (saved under the old key, so a choice made for the trail still counts). rainbow-trail.js is no longer used.
KIWI (the user): every visit to KIWI now puts the KIWI paint on; the first visit also unlocks it.
Tests: verify-places.mjs drives back home (the start screen with the colours comes back, and a new trip drives out by itself) and to Ludvig (offered at Bøckmans veg, the car parks, the game says it); verify-rewards.mjs covers the boy (six metres behind on the track, round a bend, catching up and waving, a jump starts him again), KIWI on every visit, home and Ludvig.
Why (the user): a lot of stutter and shadows popping in after the graphics polish. Frames themselves were cheap; the stalls came the first time a part of the world was drawn (its geometry sent to the GPU, its shaders compiled) and when a 160 m chunk switched its shadows on as its centre came within 110 m of the car.
All seven school buildings are now drawn by dist/dalgard.js from the pictures in docs/dalgard-references.md (2021 photograph at full size, four byarkiv photographs of 2003, NRK 2009, a FINN roof-terrace view of 2025, the Esri aerial and the 2017–2019 user-council minutes). What changed: the hall to the north-east is one brown mono-pitch roof with tall brick gables, and the school's name stands in light letters on its north-west gable (three lines, no board) over a glazed door and a timber canopy; the old porch and name board on A's north-west front are gone; the main entrance A1 (OSM node 12631332162) has glazed doors under a small timber gable on the courtyard side. The southern wings and E-bygget have dark charcoal timber walls with light fascias and black timber lantern boxes with white flat tops (the lanterns were glass pyramids); B-bygget keeps a low dark roof light. E-bygget, F-bygget, G-bygget and the dormitory (OSM 191360470, Matrikkel type 529: stepped brick units with mono-pitch roofs) are no longer drawn as ordinary two-storey houses by houses.js (addDalgardSchool returns their bounds, so world.js skips the generic builder). Footprints were checked against OSM, the Matrikkelen points and the aerial: nothing missing, nothing displaced, so add-dalgard.py is unchanged. Numbers: the school's triangles rise from 5 063 to 5 904 (counted in addDalgardSchool), but four buildings that houses.js drew before are gone from it: the world has 1 993 120 triangles instead of 1 993 674 (shoot.mjs count), and the world build in SwiftShader is 6.50 s against 6.59 s (three runs each, machine at load 8: within noise). verify-dalgard.mjs checks the name on the gable, the entrance, the pavilions' corners, footprints, ground and that nothing sits inside another building. Comparison sheets (photo | before | after) and shots: /tmp/claude-0/-home-user-Sofiatur/589d848f-5a6e-5ae7-bb65-28ec10a501a2/scratchpad/dalgard/F/. Open: wall heights and the hall's pitch are read off the photographs; the covered passage and glazed link of the 2003 photographs and the entrance steps are not modelled; G's material (OSM brick) is unverified.
The school grounds and what you meet on the way to them, from OpenStreetMap, NVDB and photographs (sources, what is measured, seen or estimated: docs/dalgard-references.md).
add-schoolyard.py (new pipeline step, after add-roadside.py; needs shapely, pip install shapely) writes data.schoolyard: the grounds (OSM relation 6579902), the asphalt yard (the grounds less
buildings, lawns, playgrounds and roads), playground surfaces, the 30 mapped pieces of playground equipment, the ball court and Trondsløkka, statue and campus board, car parks and drop-off pockets, and the
NVDB road signs (nvdb-school.json, fetched once, NLOD). NVDB speed humps and the 2024 raised junction areas go into data.street.tables.dist/schoolyard.js draws it (merged into the world's chunks): the grey asphalt yard instead of lawn, playground equipment at its mapped places in the colours of the photographs, a handball court with
goals, the bronze statue Nusse på gyngehest and its boulder, the campus board, picnic tables and a bicycle rack in the courtyard, the amphitheatre steps, bay lines and cars in the car parks, and 36 sign poles
(Barn and Skole Dalgård skole, parking, no entry, 30 zone, shared path). Roadside's generic playground equipment and the footpath ribbons inside the yard are skipped (skip in addRoadside).add-roadside.py now reads colour=* on fences) and the west chain-link fence is 1.3 m.node verify-schoolyard.mjs (data against OSM/NVDB counts, the yard clear of carriageways and buildings, equipment and signs on the ground and off the road, speed humps on their roads, skip in the
roadside, the triangle budget). All 26 verify-*.mjs pass.add-roadside.py, add-schoolyard.py.What was checked. The 353 buildings within 200 m of the school site (school buildings and grounds excluded) and the street scene along Anders Wigens veg, Odd Husbys veg, Uglagjerdet, Dalgårdvegen and Granlivegen, against FINN and DNB Eiendom listings (street, drone and courtyard photos, found through the registered address of each flat), a terrace architect's page and the Esri aerial photo (viewed only). node audit-dalgard-neighbourhood.mjs lists every building with its address and how it is drawn; fetch-dalgard-addresses.py writes data/dalgard-addresses.json (Kartverket Adresser, CC BY 4.0). Statens vegvesen's road images do not reach these streets (nearest image 363 m from the school, county road only).
What changed. 20 style records in dist/dalgard-neighbourhood.js (11 from photos, 3 by analogy with a photographed neighbour, 6 roof colours from the aerial photo), merged over the older records in junction-buildings.js; house-looks.js reads one new key, plain (render, no boards). Dalgårdstunet is cream-yellow render with grey-brown shop fronts (it was drawn as grey-green boards, and hus B as khaki); Odd Husbys veg 4 is a long block of taupe vertical timber, no. 6-8 beige render; Anders Wigens veg 22-28 are two-storey dark grey-brown timber blocks with light grey flat roofs (they were cream and three-storey); Uglagjerdet 1-7 are cream gabled timber houses (they were flat blocks), Uglagjerdet 11-19 and 46-52 three-floor terraces in dark red, green and charcoal timber; the dentist's hip roof is pale metal. add-dalgard-neighbourhood.py (run last, after add-roadside.py) adds 15 young rowans and 5 lamps along 164 m of Odd Husbys veg, where the 2023-25 street has neither in OSM; the positions are estimated from the photos.
Chain-link fences (mesh, 42 lines on the map) were opaque panels that hid the houses behind them; roadside.js now draws posts, rails and a thin wire pair every 1.5 m (+1,472 merged triangles). Budget: about +2,800 triangles in all (0.13 %, the 15 rowans are 24 triangles each, instanced), the world build time is within the noise of a loaded machine (medians of six alternating runs 7.64 s before, 7.96 s after; the added work is a few hundred operations plus 1,472 quads).
Not done. Parked cars along Anders Wigens veg and the mature lime trees by the Dalgårdstunet crossing are missing, hus A is one even slab where the photos show stepped balconies, and the terrain (DTM1) predates the new blocks, so their courtyard terrace, ramp and steps are not there. Most houses on Granlivegen, Magnus Åldstedts veg and the roads north-east of the school have no photo and keep their seeded look. Sources, matches and certainty per building: docs/dalgard-neighbourhood.md, docs/dalgard-references.md. Test: node verify-dalgard-neighbourhood.mjs (every photo record has a registered address beside its footprint, the records reach the house builder, trees and lamps are off every carriageway and clear of houses).
Performance measured (2 October 2026; the main trip driven 8 m per frame in headless Chromium, counting the bytes sent to the GPU and the shader programs linked per frame, which is what stalls a tablet): before, 160 MB were uploaded while driving, in 95 frames of more than 256 kB (the worst 12 MB) and 18 shader programs were linked on the way; now 115 MB are uploaded while the game loads, and nothing while driving (the worst frame 0.02 MB, no shader linked).
The user sent a Google Street View photo of Bøckmans veg 102 and marked where to park. Bøckmans veg is county road 6656 here, so Statens vegvesen's road images of 14 July 2025 show the place too. The parking place has moved there: on the gravel in front of the white garage west of 102C-D, reached by a short gravel driveway from Bøckmans veg. dist/ludvig.js draws the open gravel yard between the road and the houses (no trees on it), the bed of dark earth and weeds in front of 102C and the dark timber terrace with its steps at the east end, and holds style records from the pictures: 102C-D white boards under a steep dark tiled roof (one and a half floors), 102A-B a tall white three-floor pair, the garage white and flat-roofed, the new house to the west white with a dark roof. Sources and uncertainty: docs/ludvig.md. Test: verify-places.mjs.
The user: Ludvig (the boy who runs after the car) is unlocked only by visiting him, and is an alternative to the rainbow trail, switched on and off at home. So the rainbow trail is the reward for the third trip again (as before), and the first time the car parks at Bøckmans veg 102 Ludvig is unlocked and starts running after the car at once ("Du besøker Ludvig! Nå løper Ludvig etter bilen."). On the start screen the two have a switch each: switching one on switches the other off, both may be off, and the choice is saved (behind in sofiatur.fremgang: 'trail', 'ludvig' or 'none'; ludvig: 'on' once he is unlocked). Ludvig cannot be switched on through storage before the visit. world.js has both again, setTrail and setBoy. Tests: verify-rewards.mjs.
The user: new rewards for reaching the kindergarten, a dog, a duck and a rocket. They come after the four there were: the fifth trip unlocks the dog, the sixth the duck, the seventh the rocket, each chosen at once. Like the cat they take the car's place and its paint colour, are about the car's size and wear the KIWI logo on both flanks with the KIWI skin (dist/rides.js): the dog gallops with floppy ears, a wagging tail, a red collar with a gold tag and its tongue out; the duck is a mallard on orange webbed feet that waddles, its body in the car's colour, the green head and yellow bill as a drake's, wings lifting at speed; the rocket hovers over the road with a white nose, red fins, round windows and a flame behind it that grows with the speed. The start screen's cat switch is now a row "Kjør som: Bil · Katt · Hund · And · Rakett" showing the unlocked ones (saved as model in sofiatur.fremgang; a model not yet unlocked falls back to the car). The X/Z debug keys go up to seven trips; locking a model with Z falls back to the best one still unlocked. Tests: verify-rewards.mjs.
The user: find five more fun rewards for reaching the kindergarten, then a T. rex as the last one. After the seventh trip they come in this order, each switched on at once:
rides.js) in the car's colour with a golden horn, a rainbow mane and a rainbow tail, galloping.dist/bubbles.js) blown from the back of the car, drifting up, wobbling and popping (one instanced mesh). On the start screen they are a third alternative behind the car, with the rainbow trail and Ludvig: one switch each, one at a time, all may be off.All of them wear the KIWI logo on both sides with the KIWI skin; all but the fire engine take the car's colour. The "Kjør som" row has the four new ones, the X/Z debug keys go up to thirteen. Tests: verify-rewards.mjs (each trip's reward, the bubbles as an alternative, the horn button and key, sizes, logos, paint, movement, the T. rex's jaws, the bubbles rising and popping).
The user: if you drive as the T. rex with Ludvig running after it, the T. rex takes him in its mouth while he runs. It holds him by the back of his shirt in the right corner of its mouth, its jaws a little open and its head turned a little to the right, so he dangles beside its face where the chase camera sees him, swinging, still running with his legs in the air (boy.hold, the T. rex's grip and carrying in rides.js, set each frame in world.js). His track behind the car is still kept, so with another ride he runs after it again. When such a trip starts the game says "Oi! T-rexen har tatt Ludvig i munnen!". Test: verify-rewards.mjs.
With all thirteen rewards the start card was taller than an iPad screen in landscape, and at 1024×768 the footer covered the start button. Once rewards show, the card drops the intro text and the hints and tightens its rows (640 px instead of 795); if it still does not fit (Safari's toolbars, a phone on its side) it scrolls and the start button stays at its foot. On phones the title read "vikjøre": it has a space before its line break now.
The user asked to check that every visit to KIWI switches to the KIWI paint, and that Ludvig follows the car after every visit. KIWI did already, but a later visit to Ludvig only said hello: if the rainbow trail, the bubbles or nothing had been chosen at home, he stayed away. Now every visit to Bøckmans veg 102 puts him back behind the car ("Du besøker Ludvig! Nå løper Ludvig etter bilen igjen."), as every visit to KIWI puts the KIWI paint on. Also, each visit applied the rewards again, which started Ludvig, the trail and the bubbles over: Ludvig jumped from beside the parked car to six metres back. Switching on what is already on now keeps it as it is. The tests drive into both places through arrive(), as the game does, several times in a row (verify-rewards.mjs).
The user: simple, low-impact ragdoll physics on Ludvig while he hangs in the T. rex's mouth. No physics engine: his body is a weight on a string from the back of his shirt to his centre of mass (position-based: gravity, damping relative to the mouth, then the string's length, in steps of at most 1/60 s, so it behaves the same at 30 and 60 frames a second). He leans so the scruff is right over his centre of mass, trails when the T. rex sets off, swings on when it stops, swings out in bends and settles; he cannot swing into its face or over the top. His arms, legs and head are damped springs that try to hang straight down, so they lag and flop after the swing; the legs still kick and the arms flail a little (createBoyModel's dangle, createBoy's hold in dist/boy.js). It costs about 2 µs a frame and allocates nothing. Test: verify-rewards.mjs.
The user: visits count in free driving too. Driving into a parking place (within 8 m of it) is a visit, as parking there is with the arrows: KIWI puts the KIWI paint on, Ludvig's yard puts Ludvig back behind the car (and unlocks him the first time), the ice rink, Rema 1000, Bunnpris and Lianvannet say hello. A place counts again once the car has been 30 m away. Home and the kindergarten end the drive, so there the car must stop (brake) within 10 m; on the way in the game says "Brems for å parkere …". Stopping at the kindergarten counts the trip and its reward, as with the arrows (the summary says "frikjøring" instead of the number of turns); stopping at home brings the start screen back, and starting there is not a visit. The free-driving area now takes in every road node and 15 m round it, since the turning circle by Lianvannet lies west of the map's bounds (free-drive.js). Tests: verify-rewards.mjs.
The user sent a photo from the iPad: on the start screen the unicorn stood half behind the colour picker, and with the camera turned the arrows pointed the wrong way.
world.setViewShift) so the middle of the picture, where the camera looks, lies in the middle of the free part of the screen: beside the card on an iPad, above it on a phone if there is room. With the colour picker the camera looks straight at the parked car. It also holds after a swipe has turned the camera round the car, which is what put the car behind the card on the iPad. The shift eases back when the drive starts.layoutWorldChoices, world.cameraYaw). Tests: verify-game.mjs.The user: the arrow that turns off home should say "Hjem", not "Herlofsons veg". At the junction by home both arrows said "Herlofsons veg" (the road into the parking place is the end of it). The arrow, its spoken label and the queued choice now say "Hjem" for the road into home's parking place (choiceName); the street shown while driving it is still Herlofsons veg. Test: verify-places.mjs.
The user: build a system so buildings become invisible when the camera is inside them, so they never block the game. The building material is double-sided, so with the camera inside a house, or behind one, its walls hid the car (right by home, driving out of the parking place, the chase camera stood inside a neighbour's house).
data.buildings: houses.js, the landmarks, KIWI, roofs on posts), every triangle that stands up off the ground near its footprint goes to a building twin of its chunk, with the building's id on its corners (buildingId, 16-bit). The car park or the yard drawn with a building stays in the ground chunk.see-through.js walks the line from the camera to the car (0.75 m steps, through a 24 m grid of footprints) and picks the buildings it passes through below their roofs, or that the camera is inside (with a wider margin for the camera's near plane). The building right beside the car (within 2 m of it) is left alone; with the car inside one (a garage, a canopy) that one fades too. About 0.02 ms a frame.verify-see-through.mjs (which triangles are a building's, the line to the car, fading, at most eight, the shader, speed on the real map, the wiring in world.js).The user: the fire engine could not change colour; it should, with different colours of lights for the different colours; red is the standard fire engine, KIWI has green and white lights; and the free colour wheel goes from the colour picker.
BEACONS in rides.js): red blue and blue (the standard), black blue and red, pink purple and white, purple pink and yellow, blue red and white, turquoise yellow and pink, green yellow and white, yellow amber, orange yellow and blue, white red and blue, KIWI green and white; with the rainbow they go round the rainbow. world.js sets them with the colour and the skin.verify-rewards.mjs.The user: the settings menu sometimes came up without anyone pressing it; it should close on a tap outside it; and the three round buttons top right had their icons off the middle.
verify-game.mjs.The user: all the horn sounds were the voice (speech synthesis); find real sound effects online for every choice. Each ride's horn now plays a real recording: the car two short beeps, the cat a meow, the dog two barks, the duck "kvakk kvakk", the rocket a whistling firework rocket, the unicorn a horse's whinny, the fire engine a two-tone siren, the hot-air balloon its burner, the T. rex a roar. All nine are from Freesound under Creative Commons 0 (each licence checked on its page), chosen from 29 candidates, cut to the sound, faded, brought to the same loudness and encoded as small MP3s (dist/sounds/, 0.5–2.7 s, 184 KB together); the sources, the people who recorded them and what was done are in docs/sounds.md, and the menu's "Om kartet og husene" mentions them. music.js loads them with Web Audio from the start button's tap (iOS starts audio only after a gesture) and plays them one at a time, straight to the speakers; while a clip is not loaded yet, or if it fails, the voice says the word as before. The sound button switches them off with everything else. Tests: verify-horns.mjs.
The user started the game on the iPad and it seemed zoomed in so far that the menus were off the screen. The 3D view itself was not the cause (in an iPad emulation, at 1× and 2× pixels, and in the versions of the day, it draws the same); the page was being zoomed by Safari: a quick double tap on the start card, the speed box or other text, or a pinch, zooms a web page in. Now the page cannot zoom: the viewport has maximum-scale=1 (no zoom when a field gets focus), every tap is a tap (touch-action: manipulation, so no double-tap zoom; the start card and the menu still scroll up and down, the map keeps its own drags), Safari's pinch is cancelled (its gesturestart/gesturechange events), and if the page is zoomed in all the same the viewport is set again, which brings it back to full size. Tests: verify-game.mjs.
The user: the horn icon was not centred on the iPad. The button on the drive screen held the 📯 emoji as text, which iPadOS draws off the middle of the circle (the same as the sound and settings buttons before). It is a drawn icon now (SVG: a megaphone with a flared bell, a handle and three short rays, so it is not mistaken for the sound button's note), centred with a grid (display:grid; place-items:center). The drawing is centred in its box: measured in Chromium at the iPad's sizes (1024×768 and 820×1180) and a phone, the ink's middle is within a quarter of a pixel of the circle's middle. The 📯 in the unlock message is still text.
Tests: verify-game.mjs.
The user: visit the duck while driving as the duck and use the horn, and you unlock a duck that runs after the car.
dist/duck-runner.js) is the same follower as Ludvig (createBoy in dist/boy.js now takes the model to run: it keeps to the car's own track six metres behind, catches up when the car stops, stands by its rear corner and waves) with a small mallard drake for a model, like the big one in the lake: green head and white collar, chestnut breast, grey body, black curled tail, yellow bill, orange legs and feet, a blue patch on each wing; about 0.9 m tall so the chase camera sees it (Ludvig is 1.15 m). Running, it waddles (the body rolls from side to side and hops, the neck stretches, the legs swing) and beats its wings half spread; standing by the parked car it flaps them. Folded, a wing lies along the flank. 18 small meshes.duck and behind: 'duck' in the saved progress; a saved choice of the duck without having found it counts as the trail). Every honk by the water puts it back behind the car ("… løper den etter bilen igjen!"), as every visit to Ludvig puts him back.verify-rewards.mjs (the follower and its wings; finding it in route and free driving, away from the lake, without a visit, with other rides and without the horn; the alternatives; reload; storage).The user: have the horn visible and working when the car is parked at Lianvannet. The horn button was hidden whenever the car stood at a choice of road (body.choosing .horn), and Lianvannet is such a stop, so on an iPad there was no way to honk by the water, which the duck easter egg needs; in free driving it was hidden all the time, since the brake and drift buttons take its corner (the H key worked in both).
atLake() in dist/game.js) the body has the class at-lake, set every sixth of a second, and the button shows: where it always is at a choice of road, and in free driving above the brake and drift buttons (118 px from the foot, 104 px on a phone) so they do not overlap. It is shown only once the horn is unlocked (the eleventh trip); elsewhere it stays hidden at a choice of road and in free driving, as before.verify-rewards.mjs (the class by the lake in both ways of driving and gone again 30 m away; the rules in style.css).The user: a taxi for anyone who has visited KIWI, Ludvig, Dalgård ishall, Rema 1000 and the kindergarten; a "Back to the Future" hover mode once everything including the taxi is unlocked; a "go inside" button at home with a podium for all the unlocks (touch one and it says how you got it, or gives a hint), the player a small girl with brown hair; a pig to find somewhere in the house; and an easter egg that puts the girl in the hot-air balloon after she has been inside.
dist/game.js): the new state is saved with the rest (ishall, rema, taxi for "announced", pig, inside, hoverSeen, hover for the switch; old saves load fine). Whether the taxi or the hover car is available is computed, never stored (taxiReady(), hoverReady(), rideOK(model)), so the debug keys X and Z (one trip more or fewer) keep everything consistent. newUnlocks() announces each one once, after any event that can complete it (a visit to one of the five places, the running duck found, the pig found, the trip to the kindergarten), and returns words that the caller adds to its own toast and speech, because say() cancels the speech before it.createET5 in dist/car-model.js): a lit TAXI sign on the roof, a black-and-white checker band along both sides and TAXI on the front doors, a yellow ET5. Choosing the taxi (or unlocking it) paints the car yellow (the "Gul" swatch), but it takes any colour like the car, and with the KIWI paint it wears the shop's logos (the checker band then moves down to the sills). It honks like the car. Hidden group taxi, shown by world.js for model 'taxi'.createET5, createFireTruck in dist/rides.js, world.js): the car, the taxi and the fire engine hang each wheel on a hinge group; in hover mode every wheel folds flat under the body (the tyre's outer face turns down) with a cyan glow under it (hoverGlow), and the whole vehicle floats 0.85 m up and bobs, its contact shadow staying on the ground. (6 October: the folded wheels sit at the sides of the car, low under the wheel arches, with about the outer half of each tyre sticking out past the body, like the DeLorean's in Back to the Future II, so they show from the side and from a 3/4 view; they used to be tucked in under the body.) The blend eases in and out (hv in world.update), and is 0 for every other ride. The start card has a switch ("Svevebil · hjulene under bilen") once it is unlocked; it is on to begin with and remembered.dist/girl.js, createGirlModel): about 1.05 m, brown hair with a fringe and two pigtails that swing when she walks, rosy cheeks, a raspberry top, a purple skirt, white socks and purple shoes; the same API as Ludvig's model. She is the player in the house, and the passenger in the balloon: has a at the basket, puts her there () once , waving now and then. No toast, no speech, no podium place: she is just there.The user: the secret unlocks (the duck, Ludvig, the taxi, the hover car) must be counted on the start screen too, and once enough trips to the kindergarten are made it should say how many are still missing. The six secrets are found by exploring, not by trips: the KIWI car, Ludvig, the running duck, the taxi, the pig and the hover car (the last six places on the podium). After the thirteenth trip the teaser on the start card, which until then hints at the next trip, says "🔎 N hemmeligheter mangler fortsatt" and goes away when all six are found. The counter is missingSecrets() in dist/game.js and is computed from what is saved, so a visit, the pig and the debug keys (X and Z) change it at once. Since the hover car opens the moment everything else is found, the count is never 1. The T. rex card now says "alle overraskelsene fra turene" instead of "alle overraskelsene", as the secrets are still to find. Checked in Chromium at 1024×768, 820×1180 and 390×844: the card does not scroll on the iPad sizes and the start button stays in view. Test: verify-rewards.mjs.
The user: pressing X inside the house should unlock the secret rewards; it is a debug option to test every reward. (A change earlier the same day that made X and Z do nothing in the house misread this and is undone.) Inside the house X now unlocks the next locked place on the podium, in the podium's order, without doing the task: the 13 trips as before (debugArrivals), then the KIWI car, Ludvig, the running duck, the taxi and the pig, which brings the hover car with it, as in the game. The secrets run what the game runs when the task is done (parkAtKiwi, parkAtLudvig, duckHeard, parkAtIshall and parkAtRema for whichever of the two places is missing, findPig), so the toasts, the voice, the paint and the pig stepping out of hiding are the real ones. Z locks the last unlocked place again (the pig goes with the hover car, lockSecret), and a place unlocked again is announced again. When everything is unlocked or locked a toast says so. Outside the house X and Z count trips as before. Code: debugPodium in dist/game.js. Test: verify-rewards.mjs (the whole podium up and down, saved after each step, the paint, the toasts, a pig found by hand first); also pressed 20 times in Chromium from a new game.
The user: once Sofia has been to the kindergarten enough times, the start screen should give a hint about how to unlock the secret unlocks: not say how, but give good hints. From the thirteenth trip the card shows a hint (💡) right under "🔎 N hemmeligheter mangler fortsatt", for one of the secrets still missing: the KIWI car ("a secret car hides at a green shop in the neighbourhood"), Ludvig ("a friend lives close to the kindergarten, a little hidden away. He misses you!"), the running duck ("by a lake lives a huge duck; it likes to hear from one of its own"), the taxi ("the taxi comes to those who have been to many other places than the kindergarten"), the pig ("something pink hides inside your house; look for a curly tail") and the hover car ("something from the future waits right at the end"). They say where or who, never what to do, and the test checks that no hint contains words such as park, visit, honk or tap. Each time Sofia comes home (reset()) the card moves on to the next missing secret, and starts again after the last; redrawing the card (picking a colour, the debug keys) keeps the hint. The list is SECRETS in dist/game.js. With all six found, or before the thirteenth trip, there is no hint. On an iPad in landscape (1024×768) the card tightens a little while the hint shows (as it does for the taxi, the pig and the hover row) so it still does not scroll; checked at 1024×768, 820×1180, 390×844 and 844×390. Test: verify-rewards.mjs.
The horn at home (the user: have the horn visible and working on the start screen, so each ride's horn can be tried while choosing what to drive as). Once the horn is unlocked (eleven trips) a round horn button, the drive screen's, stands on the start screen left of "Gå inn" (under the header beside it on a phone); the H key works there too. It makes the chosen ride honk and hop and plays its recording, and is silent but for the hop with the sound off. It is gone while driving and in the house. honk() no longer returns on the start screen, and the horn recordings are now fetched when the game loads (not first at the start button), so the first press on the start screen plays the recording and not the voice. Tests: verify-rewards.mjs.
The KIWI logo hid under other parts (the user, with a picture of the fire engine: the logo was hidden under the locker doors). The doors' faces (x ±1.125) lay over the logo (x ±1.12); the logo now stands in front of them, over the middle ones. Looking for the same fault in the other rides with a ray from outside along the middle of each logo found two more: the duck's folded wings covered its logo completely (it is now smaller and sits below them) and the rocket's body covered the ends of its logo (it is now bent on the cylinder, not on an ellipsoid). The test The KIWI logo is visible on every ride in verify-rewards.mjs runs that ray over all nine rides, so a later change that hides a logo fails.
The user: on arriving at the kindergarten, and when starting over, start at the screen where the car is chosen.
reset() in dist/game.js. Tests: verify-game.mjs (starting over in both ways of driving), verify-rewards.mjs (the arrival card after a trip with no new reward), verify-queued-choices.mjs (starts the trip from the start screen). Checked in Chromium with real clicks in the menu.The user: a system called "RE-ward", a kind of prestige. The RE-ward button in the settings saves what is unlocked so far and resets all progress; after starting a new game, with a few unlocks or none, it is possible to roll back to the old save and its unlocks.
sofiatur.re-ward ({v:1, saves:[…]}, each save as in sofiatur.fremgang), next to the progress; a broken value is ignored and bad entries are dropped. With storage blocked the game still works and RE-ward and the rollback work while the page is open. The settings (music, camera, …) are not part of it.applySaved() and snapshot() in dist/game.js are what the game start, RE-ward and the rollback share.
Tests: verify-rewards.mjs (the two taps; everything reset and every unlock back; the reload; a stack of saves; damaged, capped and blocked storage). Checked in Chromium on an iPad and a phone with real clicks in the settings.The user: a "RE-ward" system was talked about in another chat and had not gone live. It had been built on 4 October on the side branch claude/horn-icon-ipad-centering-5qifmj, which was never merged into main, so it never reached the game on Vercel. Two commits from it are now in main: RE-ward and arriving and starting over lead to the start screen (both sections above), both marked "The user" in the README of that branch. The third commit there, a chip on the start card counting everything still locked (16 places), was left out: the start card now counts the secrets still missing after the thirteenth trip, as the user asked on 6 October (see The start card counts the secrets), and the two would show two counts.
What the new unlocks since 4 October changed in RE-ward: the progress it saves, clears and restores is the whole of snapshot(), which now also holds the places visited (Dalgård ishall, Rema 1000), the taxi, the pig, the hover car and its switch, and whether Sofia has been inside the house; applySaved() reads all of them back, and a new game has none of them (so the girl is in the balloon again only after she has been inside, and the taxi is announced again when it is unlocked again). A visited place or the pig counts as something to save, having been inside alone does not (it is no unlock). The label over the rollback names the taxi, the pig and the hover car as well: "Lagret spill: 13 turer · KIWI · Ludvig · anda · taxi · gris · svevebil" (describeSave(), which works the taxi and the hover car out from the saved fields as the game does). Tests: verify-rewards.mjs (the old RE-ward tests with the larger save, and one for everything new: saved, cleared, found again, rolled back).
The user: improve the unicorn's design. It was a toy: one white egg for a body, plain stick legs, a neck like a bowling pin, a ball head with a sausage snout, and a mane and tail of stacked ovals. The new one (createUnicorn in dist/rides.js) is a graceful pony. The body, neck and head are each lofted from cross-sections (a deep chest, withers, a tucked belly, a round rump; an arched neck; a big forehead tapering to a small muzzle with a pink nose, nostrils, a smile and a blush; big glossy purple eyes with highlights and lashes; upright ears with pink insides). The legs have a tapered thigh, a knee that bends, a cannon with a fetlock, a sloping pastern and golden hooves, with a hock on the hind legs. A long golden spiral horn carries a twinkling sparkle, six locks of rainbow mane (red to violet) follow the neck down both sides with a forelock between the ears and the horn, and the tail is six rainbow strands in two linked halves that stream and wave. It gallops in diagonal pairs with the knees folding, nods its neck, bobs and swings its mane and tail; the horn makes it rear its head back in a neigh and toss its mane. The coat takes the paint colour (the rest keep their own colours; the nose, inner ears and blush are soft pink, which suits every paint), and the KIWI logo lies on the barrel on both flanks. It is about 5.2 m long and 4.1 m high, 75 meshes and about 14,000 triangles with shared materials. Checked in the game at 1024×768 in white, pink, black, blue and KIWI paint, from the front, the side and behind, galloping with the chase camera, and as the little one on the podium in the house. Tests: verify-rewards.mjs (size, logos, paint, movement, honk, and the KIWI logo visible on every ride).
The user: visiting the duck with the tiny duck and honking spawns a smaller duck that follows the small duck; not a permanent unlock, and only there if you complete the honk double duck scenario.
ducklingHeard() in dist/game.js.createDucklingModel in dist/duck-runner.js) is a yellow duckling about 0.45 m tall, half the small duck: a round fluffy body with a pale belly, a big round head with a short orange bill and a tuft, tiny wings and short orange legs; it waddles and its legs swing as it runs, and by the parked car it flaps its wings. It is the same follower as Ludvig and the small duck (createBoy in dist/boy.js has a new option extra: metres further behind), 1.2 m behind the small duck while driving and standing behind it by the parked car.snapshot()): a reload, RE-ward and the rollback take it away; it has no row on the start card, no place on the podium and does not count among the secrets. It follows the small duck, so it is shown only while the small duck is behind the car: switch the small duck off and it goes, switch it on and it is back, for as long as the page is open.
Tests: verify-rewards.mjs (the honks and the toasts, nothing saved, gone after a reload, a new game and a rollback, not for other rides or away from the lake, and the duckling's size, distance, waddle and wings). Looked at in a test scene next to the small duck, running and standing.“Blindveier” in the settings menu now starts on “Av · bilen kjører forbi blindveier”: the car drives past dead ends and only slows at junctions where there is a real choice. “På · blindveier kan velges” still offers every road. Saved settings carry a new version (v:3 in sofiatur.innstillinger): a Blindveier value saved by an older version does not count, because it may only have been saved when another setting was changed (and the default was then on). Music and choosing ahead are kept. Someone who wants blindveier chooses “På” once and it is remembered.
Tests: verify-settings.mjs (the new defaults, and unversioned and v:2 saved values ignored for Blindveier only) and verify-dead-ends.mjs (the default is off and drives past dead ends).
The user: improve the T. rex's design, fix the mouth when it honks, and make sure Ludvig is well placed in the mouth.
jaw.rotation.x, so it passed. The head also tipped down on a roar instead of up.createTRex in dist/rides.js, built like the unicorn with lofted skins, 88 meshes, about 15,000 triangles, 1.9 × 3.7 × 8.2 m): a body with a deep chest and a tail of four swept pieces on joints that tapers and sways; a big skull with a brow ridge, big yellow eyes with slit pupils and nostrils; a lower jaw hinged at the back of the skull, with stubby friendly teeth, a tongue, a red mouth and roof of the mouth; thighs on bird-like legs (thigh, shin that slopes back, foot bone, three toes with claws) that bend at the knee as it runs; two tiny arms with two claws each; a cream belly and a row of darker plates down the back and the tail (a darker shade of the paint, a lighter one on a dark paint). The body takes the paint colour; the KIWI logo is bent onto the chest, in front of the thigh and below the arm, which would cover it elsewhere.rotation.x about the back hinge) and the snout lifts (positive), held a moment and closed again, with the same 1.2 s envelope as before. Closed, the front of the lower jaw meets the upper one.verify-rewards.mjs): the roar measured on the tips of the jaws in the world (the lower tip goes down by more than 0.7 m and never up, the snout lifts, both close again), the grip on the head and not the jaw, Ludvig not sinking into its head, neck or body (share of his surface inside its solid parts, standing, running and roaring), no jump in a roar, the grip at the right corner of the mouth, and the KIWI logo visible. Looked at in a test scene and in the game at 1024×768: standing, running, roaring, in black, blue, green and KIWI paint, with and without Ludvig.The user: fix the white belly so it does not stick out as much; improve the hands and give them claws; claws on feet and hands.
talon() in createTRex: a swept, tapered tube in bone colour that runs forward and curves down at the tip.verify-rewards.mjs (the same ones; the open belly strip is left out of the "inside its solid parts" count for Ludvig). Looked at in the test scene: standing, running, from the front and close up on a hand and a foot.The user: make the duck mouth quack when the horn is used.
beak in createRideDuck), with a pink tongue; the upper bill has a darker roof of the mouth under it, which shows when the bill is open. Before, both bills were fixed ellipsoids.sounds/horn-duck.mp3, 0.94 s) is "kvakk kvakk": one quack at 0.11–0.36 s and one at 0.60–0.85 s. honk() on the ride (the world already calls it) starts a clock, and each quack opens the lower bill (a negative rotation.x drops its front, 0.55 rad at the widest, a quick snap and a slower close) while the head lifts a little. Between the quacks and after the clip the bill is shut, and a new honk starts over.
Tests (verify-rewards.mjs): the front of the lower bill measured in the world: shut by default and without the horn, down at both quacks, shut between and after, never up through the upper bill, the head lifts and settles, a new honk restarts. Looked at in the test scene: shut, first quack, between, second quack from the front.The user: switching to the duck should set the body to the default colour (that colour seemed to be missing from the manual colour select).
DUCK_PLUMAGE #14171c in dist/game.js (the colour createRideDuck is built with). That is the Svart swatch, the first one in the row, so it is highlighted afterwards and can be picked again at any time. A rainbow or KIWI paint does not follow the duck over (the KIWI skin is dropped, as for the taxi).verify-rewards.mjs, with the taxi's): from a pink car, from a rainbow car, from the pig and from a KIWI-painted car the duck becomes #14171c with the Svart swatch on; a chosen blue stays when tapping the duck again. The test fails without the change.The user: the duck body should be dark grey, the same as the small duck (so that colour was missing from the manual colour select). This replaces the black from the section just above.
#9a9186, the body of the small running duck in dist/duck-runner.js) instead of black, and choosing the duck from another ride paints it that (DUCK_PLUMAGE in dist/game.js), like the taxi's yellow and the pig's pink. Tapping the duck while it is the duck leaves the colour; a rainbow or KIWI paint does not follow it over.#9a9186) is the last of the plain colours, after Hvit, so the duck's colour can be picked on any ride and it is highlighted when the duck has it. There are now eleven colours, then the rainbow and KIWI (13 buttons; they still wrap in two rows on a phone). A colour saved earlier snaps to the nearest of the eleven, as before. The fire engine's beacons have a pair for it (orange and blue).verify-rewards.mjs): the duck becomes #9a9186 from a pink car, a rainbow car, the pig and a KIWI car, with the Grå swatch on, Svart and Grå can both be picked on the duck, the swatch indices of the rainbow and KIWI moved from 10 and 11 to 11 and 12 (13 buttons), and every swatch has its own beacon pair. Looked at in the real game (1024 × 768 and 390 × 844: Grå highlighted, the picker wraps as before) and in the test scene (the duck from the side).The user: swap the unlock place of the horn and the rainbow colour.
dist/game.js: the reward list of finish(), the new constants HORN_AT = 4 and RAINBOW_AT = 11 (the rainbow swatch, the check of a saved rainbow colour, the debug keys X and Z and their toast), and the podium list (the horn is the fourth item, the rainbow car the eleventh). In dist/inside.js the two names swap places on the podium steps, so the podium still follows the order of the trips.verify-rewards.mjs): the fourth arrival gives the horn and its button (the colour is left alone, the rainbow swatch hidden), the eleventh gives the rainbow colour, picked at once and remembered; the debug keys X and Z lock and unlock each at its new place; the podium order and the start-screen horn button follow the new numbers; all the other verify-*.mjs scripts pass. Looked at in the real game with saves of 4, 10 and 11 trips: the horn button is there from 4, the rainbow swatch from 11 only, no errors.The user: "Fix so that things running behind the car move smoothly and not stuttery as they are now. Can it be mini collisions with the road that cause the stutter?" The things behind the car are Ludvig, the small duck and the duckling (one follower, createBoy in dist/boy.js), the rainbow trail (dist/rainbow-trail.js) and the soap bubbles (dist/bubbles.js). Measured first, on the real drive (the whole main trip, 195 s, 3.2 km, driven through the game's own state machine at 30, 60 and 120 fps) and in the real game in headless Chromium, then fixed.
y) the median second difference over 0.25 m is 0 and the 99th percentile 0.5 cm; the road does have steps, about 30 in 3.2 km (9 per km, 1 to 9 cm in 0.25 m; the biggest 17 cm in 2 m at (709, −44)), where the drawn road surface itself steps by 9 to 40 cm (surfaceTop, where road surfaces meet) and the line rises before the step. In free driving the car's height is carHeight(x,z) directly, which has 43 such steps a km along the same path (up to 21 cm in 0.25 m, 99th percentile 1.3 cm). The follower replays those steps 6 m later, but 10 to 40 a km is not what you see as a stutter; the hops (12 to 25 a second at 5 to 14 m/s) are. With the hops gone the follower's height is as smooth as the car's own (table). The one thing that really did collide with the road was the rainbow trail: its height came from the car's middle, but the band lies 2.35 m further back, so on a 19 % downhill it lay 7 cm under the road and was invisible.dist/boy.js (createBoy: Ludvig, the small duck and the duckling): every point of the track has its path length s; the car itself is the live end of the track (H, not stored), and he stands at s minus the distance behind him, so the distance is exact every frame, at any frame rate and with frames of uneven length. The stored points come every 0.2 m (0.4 before). His facing is the chord from 1 m behind him to 1 m ahead on the path (no step at the points), turning at most 10 rad/s (a bend never asks for that much; a three-point turn, where the track folds and his heading is the opposite one, used to pop through 180° in one frame and now turns round in a third of a second). His height is the track's height averaged over a metre (a triangle of five points 25 cm apart, no lag on a slope): a step in the road is a ramp of a metre and a washboard of 1 cm bumps is 1 mm. , and ease with instead of . The track starts as long as he stays behind the car (the duckling's was 1.2 m too short, so it waited for the car to lay down 1.2 m before it set off). Where the car stops he stands, waves, restarts after a jump () and is carried by the T. rex (, , ) exactly as before; is untouched.| speed | fps | hop in the distance behind the car, cm | yaw in the bend, largest change of the turn between frames, degrees | height on the hill, largest change of the slope between frames, cm |
|---|---|---|---|---|
| 5 m/s | 30 | 33.3 / 0.0 | 3.44 / 0.00 | 7.50 / 0.00 |
| 5 m/s | 60 | 33.3 / 0.0 | 2.86 / 0.00 | 6.25 / 0.00 |
| 14 m/s | 30 | 0.0 / 0.0 | 0.00 / 0.00 | 0.00 / 0.00 |
| 14 m/s | 60 | 23.3 / 0.0 | 0.41 / 0.00 | 7.00 / 0.00 |
| 24 m/s | 30 | 0.0 / 0.0 | 0.00 / 0.00 | 0.00 / 0.00 |
| 24 m/s | 60 | 40.0 / 0.0 | 0.24 / 0.00 | 12.00 / 0.00 |
| 30 m/s | 30 | 0.0 / 0.0 | 0.00 / 0.00 | 0.00 / 0.00 |
| 30 m/s | 60 | 0.0 / 0.0 | 0.00 / 0.00 | 0.00 / 0.00 |
(The old follower is fine where the car moves more than 0.4 m a frame; the new one is fine everywhere.) The whole real drive, 99th percentile and largest second difference per frame, Ludvig (the ducks are the same within 1 cm), old / new, with the car's own value for yaw and height as the floor:
| fps | distance behind the car, cm | yaw, degrees (the car's own) | height, cm (the car's own) |
|---|---|---|---|
| 30 | 76.9 and 79.9 / 0.28 and 0.58 | 5.26 and 10.4 / 0.36 and 1.42 (0.29 and 2.90) | 9.8 and 29.0 / 1.45 and 10.5 (1.9 and 9.9) |
| 60 | 78.5 and 80.0 / 0.13 and 0.47 | 4.31 and 9.8 / 0.12 and 0.54 (0.09 and 2.53) | 13.8 and 25.6 / 0.43 and 4.6 (0.66 and 6.2) |
| 120 | 70.2 and 80.0 / 0.05 and 0.24 | 3.11 and 8.8 / 0.05 and 0.26 (0.03 and 2.30) | 9.7 and 22.8 / 0.13 and 1.5 (0.20 and 2.8) |
The same in the real game (headless Chromium, the game's own code, drawing switched off, the game's time fixed at 1/60 s a frame, 44 s of driving, 1900 driving frames; the small duck gives the same): the second difference of Ludvig's distance behind the car, 99th percentile and rms, 78.6 and 45 cm → 0.13 and 0.02 cm; yaw 4.55° → 0.10°; height 19.9 cm → 0.76 cm (the car's own 0.80 cm). At 120 fps: 67.9 and 36.4 cm → 0.07 and 0.01 cm. On the screen at 1024×768 (consecutive frames at 13 km/h): the old Ludvig slid back 4.6 px a frame and then popped forward 24 px (he is about 75 px tall); the new one moves 0.2 px a frame. The rainbow trail's front edge against the bumper, old / new: 0 to 58 cm behind it, jumping 47 to 58 cm in a frame at 5, 14 and 30 m/s at 60 fps / 0.
verify-rewards.mjs, after the follower tests ("Followers move smoothly …"): for Ludvig, the small duck and the duckling, at 22 (the game's slowest frame, 45 ms), 30 and 60 fps: the distance behind the car changes by under 5 mm between frames at 5, 14 and 24 m/s and is 6 m (7.2 for the duckling) in every frame; frames of uneven length do not move him against the car; in a bend his yaw changes by under 0.3° between frames and he turns with the car; on a 15 % hill he stands on the road 6 m back to within 1 cm and his height changes by under 3 mm between frames; an 8 cm step in the road is a ramp (under 60 % of the step in one frame) and a 1 cm washboard is under 3 mm on him; the same bend at 30, 60 and 120 fps puts him in the same place (3 cm, 0.5°); a three-point turn does not pop him through 180°; the duckling keeps its 7.2 m from the first frame; the rainbow trail starts at the bumper at 5, 14, 24 m/s and stays 20 cm above the road on −19 %, 0 and +19 %; a bubble keeps its tint. All of them fail on the old code (89 assertions) and pass now; the T. rex carry tests and the other follower tests are unchanged and pass. verify-game, -places, -inside, -horns, -settings, -queued-choices, -free-drive, -see-through and -driving-line pass.createBalloonseatworld.jssetBalloonGirlbeenInsidecreatePig in dist/rides.js): a big pig, car-sized (4.8 m long), a curly tube tail that wiggles, floppy ears, trotting legs, hops on honk, the KIWI logo on its flanks with the KIWI skin. It is found in the house. It is pink to begin with (finding it, or choosing it from another ride, picks the Rosa swatch; with no trips yet, when the colour picker is not there, it is pink too), but takes any colour like the dog and the cat: the body is the paint and the snout, tail, ears' insides and hooves are shades of it (lighter on a dark paint), eyes and nostrils stay dark. The pig in the house and on the podium is always pig-pink. Its horn is a real recording like the others' (dist/sounds/horn-pig.mp3: two grunts of a large pig, Freesound, CC0, docs/sounds.md); the voice says "Nøff nøff!" only while it is not loaded. The taxi honks like the car.dist/inside.js, createInside): the "Gå inn" button (bottom right on a wide screen, under the header on a phone) shows only on the start screen. It opens a room with its own scene and camera, drawn with the world's renderer, so both the WebGL and the software renderer work: a wooden floor, three walls (the front is open like a dollhouse), a window with curtains, a child's drawing of the car and a rainbow, a rug, a sofa, a lamp, a plant and a door. The podium against the back wall has three steps and one place for each of the 19 unlocks, in order: 13 for the trips, then KIWI, Ludvig, the running duck, the taxi, the pig and the hover car. An unlocked place shows a little turning model of the real thing (fitted to a metre), or a medallion with its emoji where there is no model (colour, trail, bubbles, horn, hover); a locked place a grey "?" that bobs, never the model. A tap (pick, a ray against invisible hit boxes) on a place walks the girl to it and game.js shows and speaks how it was unlocked or, for a locked one, a hint (the missing places for the taxi, "én gang til" or "n ganger til" for the trips). A tap on the floor walks her there, clear of the sofa and the podium; the door, "Gå ut" and the menu's restart lead back. The camera backs off until the whole room is in view, also upright on a phone. While inside, the world is not drawn.verify-rewards.mjs (the taxi in several orders, its colour, the hints naming the missing places, the hover car and its switch and the debug keys, the house with a stubbed createInside, the girl in the balloon, the pig; the new models with real three.js), verify-inside.mjs (inside.js with a fake renderer and canvas: 19 hit boxes, a tap at the middle of every place at five screen sizes, the pig, the door, the girl walking), verify-horns.mjs (the changed honk()). Checked in Chromium with the WebGL and the software renderer at 1024×768, 768×1024, 820×1180, 390×844 and 844×390.behindwaverun1 − exp(−dt·k)dt·kgap > 12heldhold()danglecreateTRexdist/rainbow-trail.js: the band starts at the bumper itself (a live end; it started at the last point laid down, up to 0.6 m behind the bumper, and jumped forward 0.3 to 0.6 m at every new point: its front edge flickered), the fade-in at the front goes by distance (1.8 m) and not by number of points, and its height is the road's under the bumper (facing.y / h, the slope, clamped to ±60 %).dist/bubbles.js: each bubble keeps its own tint. The instance slots move up when a bubble is blown or pops (newest first) and the tints were fixed per slot, so every bubble changed tint nine times a second or more. The positions are fine: they are placed at the car and then move by dt.run and on dt, nothing in it is quantised, and it is meant (the 6 cm hop of the run is the run's step). Not touched.v²/3, at least 8 m: 3 m/s² sideways, as the car is held to) and a 15 % hill at a constant speed (the first 4 s left out); largest change from one frame to the next, old / new: