← writing

Two side quests I never quite finished.

Praise Ofumaduadike
Nigeria·May 2026·~8 min read
Side QuestsWICGThree.jsExperiments

There's a page on this site called Side Quests. It's where I put things that were real enough to build but didn't land the way I wanted. Not abandoned exactly. More like paused in a state of honest incompleteness.

This is the story behind two of them — what I was actually trying to do, how each one broke down, and what shipped instead.

Part 1Domain Expansion

Where the name comes from

In Jujutsu Kaisen, Domain Expansion is the highest-order technique a sorcerer can use. The sorcerer manifests their innate cursed domain — a separate space that only exists inside their own technique — and expands it outward until it envelops everything around them. Inside the domain, every attack hits with guaranteed accuracy. There is no dodging it. The domain overwrites local reality with the sorcerer's own.

Gojo uses Infinite Void. Sukuna uses Malevolent Shrine. I wanted to use dark mode.

The idea was simple: click the theme toggle and the dark domain expands outward from the button, an organic writhing blob that consumes the screen and overwrites the light theme underneath it. The sorcerer's space, but for CSS.

The theme toggle on this portfolio does exactly what you'd expect: add or remove a class, write to localStorage. Two lines. It works. That wasn't the original plan, but to understand why requires a small detour through a browser API that almost made the whole thing possible.

The visual I had in mind was clear: a blob — layered sine noise, organic edges, nothing perfectly circular — blooming from the toggle button position and spreading across the screen. The old theme frozen underneath it. The new theme revealed as the blob retracts. Pixel-perfect. Native. The actual page rendered into a texture, not a screenshot-library approximation of it.

I knew this would be hard. And then I found out about the WICG html-in-canvas proposal and thought: this is exactly the technique I need.

The pitch: a new drawElementImage method on CanvasRenderingContext2D that captures any element the browser already knows how to render — fonts, shadows, backdrop-filter, all of it — into a canvas texture in a single native call. No screenshot library. No DOM-to-canvas reimplementation that misses half your CSS. Snap the current theme into a texture, swap the DOM underneath, animate the texture away with a WebGL blob shader. The domain expands. The new theme bleeds through the ink.

Available in Chrome Canary behind a flag. I enabled it. I started building. I had the technique. I was Gojo.

The constraint nobody documents

The method exists. ctx.drawElementImage is callable. The flag works. The constraint — not documented anywhere that would stop you before you started — is that it only accepts elements that are direct children of the <canvas> element itself. Not document.body. Not document.documentElement. To capture your whole page, your whole page would need to live inside a <canvas>.

What that would require: restructure the entire Next.js app so the layout root is a canvas element. Canvas children are normally fallback content — invisible in regular rendering. The WICG flag makes them render visually, but you're now inside an element screen readers treat as decorative. Accessibility, SEO, z-index stacking contexts — all strange. And it works only in Chrome Canary, for approximately no one.

I called drawElementImage(document.documentElement, ...). It threw. Every time. Turns out I was not Gojo. I was some guy who enabled a flag in Chrome Canary and hit a hard constraint nobody bothered to document.

What I built instead

Two pivots. Both live on the side quests page as interactive demos.

#01Domain Expansion

Tried

Two live iframes of the homepage — light below, dark above — the parent clips the dark layer with an organic CSS clip-path blob driven by layered sine noise. Click the theme toggle inside the iframe's floating header and the blob expands from the button position, then retracts on reverse.

Why it didn't land

Works fine as a demo. The limitation is that it can't become the real toggle — you'd be embedding your own site inside your own site and keeping them in sync live. The postMessage latency and iframe load time make it impractical for an actual interaction. It's a proof of concept that proves the concept and nothing more.

Ships as a demo. The actual toggle does classList.toggle.

#02Dilapidating Pixels

Tried

A second take on the reverse direction. Light → dark grows the blob. Dark → light: html2canvas snapshots the dark iframe, SVG cracks etch across the frozen image, then the snapshot shatters into a Voronoi tiling — each shard keeps its slice of dark-mode pixels as it falls, rotates, and fades — revealing the live light iframe underneath.

Why it didn't land

html2canvas is a JS reimplementation of the CSS cascade. It misses backdrop-filter, some custom properties, certain font rendering subtleties. The snapshot is close but not identical. On slower connections the pause before the cracks start is noticeable. The effect is theatrical when it works and slightly uncanny when it doesn't.

Ships as a demo. More theatrical than the blob. Still not a real toggle.

The theme toggle is two lines and a CSS transition already in the stylesheet. You click it and the page changes color. That's the right call — and not just because the API didn't cooperate.

Even if drawElementImage had worked, there's a real design question buried in it: how much animation is appropriate on a utility interaction? Domain Expansion is theatrical. That's the point. But a dramatic 820ms effect on something you might toggle a dozen times in a session — checking readability, adjusting to ambient light, just preferring one mode today — starts to feel like the interface is performing at you rather than responding to you. Reduced motion users would need it disabled entirely anyway. The two-line toggle with a CSS fade is fast, respectful, and never gets in the way. The theatrical version belongs in a demo. That's exactly where it ended up.

The thing I originally wanted — native, pixel-perfect, full-page capture — still doesn't exist. The spec is early incubation. The API requires an architecture no production site would choose. When it ships properly, I'll revisit whether the effect is worth the drama.

Part 2Alpine World

Where it started

The footer on this site has a link that now says "side quests." It used to say "the playground" — which was a more accurate description of the original intent. Not a collection of experiments. A place to go. Somewhere to actually sit for a minute and decompress from the portfolio-browsing.

The plan was an easter egg: click the link, land in a world — a fully immersive alpine environment, camera path through Lauterbrunnen valley, spatial audio shifting as you moved through zones, cowbells in the distance, waterfall sounds getting louder as you approached the cliff edge. The name changed to "side quests" later, when the world didn't quite become what it was supposed to be, and other experiments joined it. But it started as a playground. One world. One path. Somewhere to go.

I had never been to Lauterbrunnen. I'd seen photos. That was enough to convince me I could recreate it in a browser.

The ambition was completely disproportionate to my experience with 3D graphics. That's usually when the best projects start.

The playground was supposed to do two things simultaneously: give visitors a genuine break from portfolio-browsing, and prove — without stating it — that I could build things that had no business existing on a portfolio site. A 3D world with real terrain, spatially rendered audio, and six distinct biomes isn't something a designer puts in their footer. That was exactly the point.

Six biomes along one continuous path. Meadow opening into pine corridor. Pine corridor narrowing into alpine lake. Lake giving way to snowfield. Exposed ridge. Then descent. No UI. No minimap. Nothing to do except walk and listen. The audio was supposed to be 8D — spatially rendered, shifting as you moved, so it actually felt like being somewhere rather than watching a screensaver.

What came together

The terrain came together. Heightmap generation, physically-based shaders, the way light catches the ridge at a low angle — that part worked. The audio system worked. The spatial positioning, the zone transitions, the way the soundscape shifts as you cross from meadow into trees — that felt right. The birds worked. I got the birds right.

The HUD — speed controls, mute toggle, zone labels — is genuinely functional. The zone names appear as you cross into each biome. The headphones modal on first load is a nice touch. The loading screen with the progress bar has the right weight to it.

What broke

The terrain data came from OpenTopography — real SRTM elevation at 30-metre resolution, the actual shape of Lauterbrunnen valley. It exported as a GeoTIFF. Converting that to a PNG that Three.js could read as a displacement map seemed like a straightforward step. The conversion ran without erroring. The output was completely flat.

A flat heightmap means a flat terrain mesh. Every vertex at the same Y value — no cliff walls, no valley floor, no river channel. And because every object in the scene was positioned relative to terrain height, everything placed itself at the same wrong Y. The trees appeared to float in midair. The lake sat suspended at the wrong elevation. All the layers were vertically displaced from each other, like a diagram with the spacing turned up. One silent conversion failure. Three things that looked like unrelated bugs.

The cascade: the foliage instancer read terrain slope data to decide where to place each tree — dense in flat areas, sparse on cliff faces, nothing above the treeline. With no valid slope data, it scattered everything uniformly at the wrong height. The audio system had its own issue — a React context conflict inside the R3F Canvas that I'd have found if I'd had a working scene to debug inside. Twelve audio recordings. No sound. Three failures from one broken pipeline.

I documented all of it — where each asset came from, what the conversion step was supposed to do, which three things failed and why they were all the same root cause. The next session starts with the heightmap pipeline, verified completely, before touching anything else. There's a full sourcing log if you want the site-by-site detail.

I tried to build a valley. A silent conversion error rendered it flat, and everything else floated above it.

What I did instead

The sidequest page still has the Three.js scene. The terrain is real. The audio is real. The birds are there. Walk it with headphones and it's worth the walk — it just isn't the place I was trying to take you.

Below it on the page is a YouTube embed of the actual Lauterbrunnen valley. I put it there without irony. Since I couldn't build the valley, here's the real one. The waterfall sounds are better. The trees are convincing. It autoplays on mute.

I didn't plan that as a design decision, but it turned into one. The gap between the Three.js scene above it and the video below it — the honest distance between what I tried to make and what the place actually is — says something I couldn't have said with the finished world.

Neither of these is a failure story exactly. The domain expansion demos are genuinely interesting to interact with. The alpine terrain is something I'm proud of. I understand the WICG constraint now at a level that takes most people a failed build to reach. I understand what trees cost in a browser scene.

But I also want to be straight: these are things that didn't land the way I wanted. The original ambitions were real. The outcomes were honest compromises. That's what a side quest is.

See them running

Side quests →

A portfolio should not be a scaffold for work. The work starts from the portfolio.

Overheard somewherea thought while you wait