Song of LaritheaTurn-based tactical RPG

Home / DevDiary / It wasn't a loading problem

It wasn't a loading problem

03.07.2026

When I wrote "There is no engine" I named the two things an engine would have handled for me: collisions and sprite animations. Sprites was the spicier one. Every engine — Unity, Unreal, Godot, PixiJS — decodes each image once, uploads it to a GPU texture, keeps it resident, and draws through one renderer. The browser can't evict what it doesn't own, and a preload can't lie about what will paint. On the web, without an engine, none of that is free.

Since the alpha rollout started in June, PostHog has been logging real sessions on real connections. Blank backgrounds, fight fields black for a beat before sprites landed, green flashes where art should be. Not every session. Just often enough.

I spent most of two weeks making the preload gate smarter — real onLoad signals, retry with backoff, connection-aware decisions, WebP conversion. Every change moved the needle a little. None fixed it. DevTools on a throttled 4G session then showed every asset arriving — 313 requests, 15.2 MB, all 200s. The bytes were cached. They just weren't being painted.

The cause was two problems I'd been conflating. Static blanks (a card that never paints on mount) were a renderer-mismatch problem: I had React Native's `<Image>` (which on web becomes a CSS `background-image` on a `<div>`), react-native-svg's raster image, and expo-image all in play — and my gate was warming one renderer's cache while the surface drew with another. Motion blanks (fight field going black mid-zoom) were different: the browser re-decodes large bitmaps *during* pan-and-zoom, and no preload gate can save you from that.

Two problems, two fixes. Consolidating every static image onto expo-image — a real `<img>` — fixes the mount blanks alone, without a byte of Skia. What actually justifies Skia is the motion case: any surface that pans and zooms (fight field, walking, story) is now a Skia canvas with sprites drawn from resident GPU textures. Move-and-zoom becomes a redraw, no re-decode, no mid-motion blank. I considered putting the UI on Skia too and rejected it — CanvasKit can shape text, but there's no DOM, no accessibility tree, and no native scrolling on a canvas, and I'm not rebuilding a UI framework on top of one. 8)

Skia isn't free. There's a one-time WASM download at boot, which I self-host so it still loads on networks that block third-party hosts. And "resident texture" isn't "safe forever" — GPU memory has its own limits, and the pattern that stops the mid-motion blank on one device can create fresh trouble on another. A proper texture budget is next on the list.

← Back to diary