From One Game to a Platform: A Shared Remix Shell and a Swipeable 3D Game Feed


Klooboo is a place where kids remix games — swap the sky, the ground, the character — and share what they make. For a while it was exactly one game: a three.js endless runner (“Boo Runner”) with a “Make it yours” editor bolted directly into it.

To become a platform, it needed two things it didn’t have:

  1. A way to add a second game without rebuilding the entire remix editor.
  2. A way to browse and switch between games — right now the only way to reach a new game was to type its URL.

This is the story of both: a shared remix shell that any game can plug into, and a swipeable feed that turns the home screen into a row of live game previews. The shell was a clean refactor. The feed was a brawl with the browser.

Part 1 — One “Make it yours” shell, every game

The runner’s editor was good: a framed live 3D stage, a row of layer tiles (Sky / Ground / Character / …), a draggable bottom drawer with tabs, Share and Play. The problem was that all of it lived inside the runner. A second game — a 2.5D platformer, “Boo’s World” — would have to reimplement the whole thing.

So we pulled the editor out into a shared shell driven by a per-game spec. The shell owns all the chrome; each game describes what its layers and tabs are, and supplies the render functions and lifecycle hooks:

interface RemixTab  { id: string; label: string; render(body: HTMLElement): void; }
interface RemixLayer {
  key: string; icon: string; name: string;
  tabs: RemixTab[];
  thumb?: () => string;     // live CSS background for the tile
  onOpen?(): void;          // e.g. focus mode: stage just this object
  onClose?(): void;
}
interface RemixSpec {
  title: string; subtitle: string;
  layers: RemixLayer[];
  onPlay(): void;
  onShare(): void;
  onDrawerResize?(studioBottom: number): void; // game lifts its camera above the open drawer
}

The shell renders tiles, tabs, the drawer drag/snap, and the framed aurora stage. A game that’s mostly declarative (the platformer) builds its spec from helpers (styleTab, settingsTab, aiTab). The runner — which has scene-coupled tabs (canvas-drawn sky cards, GLB shape pickers, AI generation that loads models into the live scene) — supplies hand-written render()s.

The migration’s bar was pixel-identical: the runner’s editor had to look exactly the same after moving onto the shell. We verified that the only way that actually holds up — a before/after screenshot pass at a phone viewport, font-stable, every tile and tab and the focus-mode staging compared frame by frame. (One subtlety it caught: the old drawer text was accidentally rendering in system-ui because a global stylesheet overrode the brand font; the shared shell set the brand font correctly, which technically changed the pixels. We kept the correct font.)

The payoff: adding game #3 is now “declare a RemixSpec.” Both games share one editor, one drawer, one frame.

Part 2 — The feed: browse, then enter

With two games sharing a shell, the home screen could finally show both. The shape we wanted was a vertical, TikTok-style feed: each full-screen page is a game’s world auto-running inside the framed studio preview; you swipe between games and tap one to drop into its editor.

The deliberate decision was browse-then-enter: the feed is for browsing live previews; editing happens after you enter. That avoids a gesture clash (the editor’s drawer also drags vertically) and means only the centered game has to run live.

The frame itself became the third thing we extracted — the aurora-masked rounded “stage window” is now a small shared module used by both the editor and the feed, so a feed page and the editor are visibly the same surface.

Routing stayed boring on purpose: / is the feed; /play/<id> and /g/<code> still open a game or a shared remix directly, so links you share land exactly where intended. App.tsx became a tiny history-based router so entering a game and pressing Back are instant, no full reload.

That’s the part that demoed well in five minutes. Then we tried to actually use it.

Part 3 — Four ways full-screen 3D fights a scroll feed

Here’s the thing nobody tells you: a scrollable React feed and a full-screen three.js game have opposite assumptions about who owns the document. Putting one inside the other surfaced four separate failures, each of which looked like “the feed is broken” but had a different root cause.

1. The game froze the entire page

The runner’s stylesheet — written when the runner was the whole app — did this:

html, body { overflow: hidden; touch-action: none; }

Perfectly reasonable for a full-screen game. But that stylesheet loads as soon as the game module is imported, which is at app startup. So the moment the app loaded, the whole document had touch-panning disabled — and the feed couldn’t scroll.

The fix is a one-liner in spirit: a game’s input CSS belongs on the game’s canvas, not the document.

/* before: html, body { touch-action: none }  — freezes everything  */
/* after:                                                            */
#c { touch-action: none; }   /* only the game canvas eats swipes    */

Lesson: when a component used to be the page, assume it has reached out and grabbed html/body. Scope it down before you embed it.

2. Going back to a game crashed React

Scrolling forward worked. Scrolling back to the first game took the whole feed down with Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node — and in React 19 an uncaught commit error unmounts the entire tree, so the feed just vanished.

The cause: we mounted each game preview by handing the game a React-managed <div>, and the game filled it imperatively (root.innerHTML = …, appendChild). When React later re-rendered that div (toggling a poster, or unmounting on scroll-away), its reconciler and the game’s hand-written DOM disagreed about what the children were.

The fix is a discipline, not a hack: give imperative content its own div that React never renders into.

// React owns the page + overlay + CTA. The game and the frame each get a
// dedicated mount node that React keeps empty — no reconciliation conflict.
<div className="kbfeed-page" onClick={onEnter}>
  <div ref={mountRef} className="kbfeed-window" />   {/* game mounts here   */}
  {!live && <img className="kbfeed-poster" src={poster} />}
  <div ref={frameRef} className="kbfeed-frame" />    {/* studio frame here  */}
  <div className="kbfeed-overlay">…title / tagline…</div>
  <button className="kbfeed-cta" onClick={enter}>Make it yours →</button>
</div>

We also made the whole preview pointer-events: none — it’s display-only, so taps and swipes pass straight through to the feed, and only the page and CTA are interactive.

3. Loading a model after you’d already scrolled away

With the crash gone, a subtler one surfaced: scroll to a game and quickly back, and occasionally a texture or GLB that was still downloading would resolve into a preview that had already been torn down — calling scene code on a disposed instance and throwing.

Async callbacks outlive the thing that started them. Every loader gets a guard:

loader.load(url, (gltf) => {
  if (disposed) return;   // the preview was disposed mid-download
  attach(gltf);
});

4. The “jump” on return

By now scrolling worked — but returning to a game showed a visible pop: its poster, then a black canvas, then the scene rebuilding over a second. Because to save GPU we disposed a preview the moment it left the screen and recreated it from scratch on return.

For a feed of two games that trade-off is wrong. We keep the neighbours alive:

// 0 = only the centered page is live. 1 = keep ±1 neighbours mounted, so
// returning to a game shows its already-running scene with no rebuild.
export const LIVE_NEIGHBORS = 1;

One extra WebGL context is nothing for a two-game roster; if the roster grows we’ll switch to “keep mounted, pause the off-screen one.” The point is the knob exists and the jump is gone.

What I’d take to the next embed

  • A component that “was the app” has global side effects. Grep for html/body/position: fixed before you drop it into something scrollable.
  • React and imperative DOM coexist fine — in separate divs. Never hand React’s own nodes to code that mutates them by hand.
  • Async loaders need a liveness check. if (disposed) return is cheap insurance against the user being faster than your network.
  • Headless browsers lie about scroll. My automated wheel-scroll passed the whole time; the freeze only showed on a real trackpad and phone. For touch and scroll feel, a human is still the oracle — the automation is for catching crashes and regressions, not for signing off the feel.

The shell and the feed shipped together: klooboo.com’s play surface is now a vertical feed of live game worlds you can swipe between and remix. Adding the next game is a spec and a preview function — and it’ll show up in the feed automatically.