This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A site map you walk through. Explore Toronto is a low-poly downtown at night — real streets, real landmarks — where each feature of this site gets an exhibit at the landmark that suits it: Web Vitals on the Yonge-Dundas billboards, the design system at OCAD, fantasy NBA at the arena. WASD to walk, E to step into a feature. This is how a “game” fits into a codebase that insists on TDD and pure functions.
Nothing in the render loop makes decisions. All of it lives in src/lib/world as plain data-in data-out functions: input turns held keys into a normalized move vector, movement integrates one frame of acceleration, friction, turning, and bounds, colliders resolves a circle against building footprints with sliding, and proximity answers “which exhibit is the player standing at.” The R3F component calls stepPlayer once per frame and copies the result onto three.js objects — that’s its whole job.
That split is what made TDD possible for a feature that looks untestable. The movement spec reads like game-feel requirements: diagonals aren’t faster, top speed is asymptotic, turning takes the shortest arc across the ±π seam, a huge dt after a tab switch can’t teleport you, and two 30fps frames land where one 60fps pair does. jsdom never has to mount a canvas.
The city is data first: a street grid named after the real one (Yonge, Queen, Spadina, University, Front) and hand-placed landmark plots, then a seeded generator fills the blocks between roads with towers. The seed is 416, obviously. Because generation is index-based and deterministic, the skyline is identical on every load — and snapshot-tested.
featureId must exist in FEATURES, stand on ground the collision system says is reachable, sit inside the world bounds, and keep enough distance from its neighbours that two placards can never fight over the player.The whole generic skyline is one instanced mesh — one draw call with per-instance color, windows via a shared emissive texture. Exhibit dioramas animate only when the player is within thirty units, so a dozen idle useFrame callbacks cost a distance check each. There are exactly two real lights plus a lantern point light that follows the player; shadows are faked with blob circles. The canvas pauses entirely if it scrolls out of view, and reduced-motion turns off every ambient animation while leaving the world fully walkable.
The world dresses for your clock — day, golden hour, or night, computed from local time with night as the fallback. A fidelity slider scales every curve from chunky low-poly to very smooth (it feeds a context inside the canvas that geometry segment counts read). The explorer has a wardrobe — Jays, Raptors, Tempo, and a silver number 1 — plus a jump with real gravity in the tested core. And the exhibit list doesn’t teleport: it speed-runs the character through the actual street grid, planned by a tiny road-graph router, before opening the feature.
And the city is haunted, gently. A translucent ghost replays your own previous stroll — sampled locally, capped, and stored only in your browser — or, on a first visit, a generated tour that walks six exhibits along real streets. It’s the multiplayer-shaped feature with zero infrastructure: the recording throttle, snapshot shape, and replay interpolation are exactly the pieces a live presence layer would reuse.
And now it does: with a realtime key configured, other live visitors walk the same city as outfit-colored explorers with curated animal names — snapshots throttled to ten a second, zod-checked at the boundary, interpolated a beat behind so movement stays smooth, gone after five silent seconds. Without a key, your own other tabs count as company. It all sits behind a real flag in the flag console, and when the room is empty the ghost takes the shift again.
Twenty-five TTC tokens are hidden around the city and three of them float above the street, so the jump exists for a reason. The minimap starts fogged and clears block by block as you walk it, counting only cells you can actually stand in — a test derives that set from the collision data, so “100% explored” is a promise the world can keep rather than a number that taunts you.
Two fixes ride along. The chase camera used to get shoved out of building footprints and occasionally lose the explorer behind a tower; now it samples its own sightline and shortens the boom until you’re visible, snapping in fast and easing back out. And the fidelity slider no longer starts at a blind guess — a first visit reads core count, memory, and pointer type, so phones open low-poly and workstations open near the top.
The 501 Queen runs a real timetable — steady between stops, four seconds waiting at each — which is what makes it boardable: you walk to a stop, the car pulls up, and E puts you on the running board with the camera along for the ride. One pure function drives both the car you see and the car you’re standing on, so they can never disagree.
At the foot of the CN Tower, E takes the elevator: the camera lifts to the pod and the city lays itself out below with every landmark one click away. The interact key is context-sensitive now, and the scene decides what it means from where you’re standing. Elsewhere the leaves turn with the calendar, December strings lights over Nathan Phillips Square and snow onto the conifers, and five raccoons work short patrols that they abandon the instant you get close — the most accurate thing in the entire city.
The site already knew the visitor’s weather — the landing page has been using it for ages — so the world just asks the same hook and dresses accordingly. Rain goes straight down, snow wanders, fog pulls the skyline in close, and a thunderstorm gets a light that flashes on an irregular timer. The particle budget rides the fidelity slider, and the whole field follows the camera so a few hundred points cover a whole city.
Photo mode is the other half: P parks the simulation, clears the HUD, and hands the camera over to orbit freely. The shutter is the interesting part — instead of paying the cost of a preserved drawing buffer on every frame forever, it renders one frame synchronously and reads it straight back out before the browser clears it.
Three of the twenty-five tokens float above the street, and the fourth outfit in the picker is a locked ???. Collect every token you can reach on foot — pointedly not those three — and the mystery turns out to be a very tall man. Put him on and the last three are simply within reach, no jumping required. The whole loop is four small pure functions and it took longer to describe than to test.
Building him taught me something about rigs: scaling the whole body made a giant, not a lanky one. The fix is to keep the torso and head ordinary and let only the limbs grow, raising the hips by exactly the extra leg length so the feet still meet the pavement. Elsewhere, you can now jump onto a passing streetcar and ride the roof, and the walk-me-there feature stops on arrival to ask whether you actually want to go in — getting somewhere is not the same as agreeing to leave.
The city is audible now, and there isn’t a single audio file in the repo. Traffic is low-passed noise with a slow swell on the cutoff; the lake is the same noise through a band-pass breathing in and out; rain is brighter and denser; the streetcar is two detuned saws behind a filter, which turns out to be most of the way to an electric motor. Footsteps and jumps are short noise bursts with an exponential decay, and picking up a token is a triangle sweep.
What you hear is a pure function of where you’re standing — traffic swells toward the core, waves only exist near the shore, the streetcar rises and falls as it passes, and snow muffles the whole thing. Browsers rightly refuse to let a page make noise unprompted, so the audio context isn’t created until you actually press something, and the toggle remembers your answer.
The desktop HUD is a rail down the right edge and a placard along the bottom, which on a 390px screen turned into one pile: the rail took nearly half the width, the placard ran the rest of it, and the site menu sat on top of both. Widths that never touch on a laptop all land in the same place on a phone.
So the phone gets a budget instead of a layout. The top 64px is the site menu and one toggle — which doubles as the token counter, so the number you glance at is also the button you press. The bottom 144px is the joystick on one thumb and E and Jump on the other. Everything between the two belongs to the cards. Tapping the toggle slides the whole rail in over the city and dims it; tapping the city puts it back.
It’s all max-md: overrides rather than a mobile-first rewrite: the variant can’t match above 768px, so the desktop HUD is provably the same file it was. A viewport hook would have been easier to unit test and wrong on the first paint — this route renders on the server, which has no viewport to read. The geometry is checked in a real browser at 390×844 instead, because jsdom has no layout engine and would have happily agreed that the placard was fine.
The bug hiding underneath all that: touch had no way to press E at all. You could walk to the streetcar and never board it.
The canvas is decoration by ARIA standards, so everything it can do has a DOM twin: an “All exhibits” panel lists every feature as a real link with its landmark, the placard that slides up near an exhibit contains a real <Link> next to the E shortcut, and the minimap is labeled SVG built from the same road data as the world. Touch devices get a pointer-events joystick instead of the keyboard legend.
Update — August 10, 2026
Everything above is about building the world. This is about the part I got wrong, which was assuming that a thing which is fun to build is therefore usable.
There was no way out. The world takes over the screen, and the only exit was the browser back button — fine if you arrived by link, useless if you did not. It has its own exit now, and leaving asks first, because losing a walk you were enjoying to a stray keypress is a bad trade.
The right rail would not sit still. Panels were laid out in normal flow, so any panel changing size moved every panel below it, and the whole rail resized as content came and went. Laying it out as a grid with one fixed width, and letting only the list scroll, fixed it. This is the same failure as the calendar jumping while you scroll: content changing size above other content, with nothing holding position. Different feature, same root cause, and I did not recognise it as the same thing until I had fixed both.
The phone got its own HUD rather than a squeezed desktop one. Every control visible at once works on a monitor and is unusable on a phone, where the controls would cover the thing they control. One toggle and three bands, because the honest answer on a small screen is fewer controls rather than smaller ones.
The sound needed the browser's permission. The soundscape is synthesised in the browser with nothing to download, and it was silent for most people: audio contexts start suspended until a real user gesture resumes them. Resuming on first interaction and lifting the levels turned a feature that technically worked into one that audibly did.