Edenverse v0.7.0: 10x Faster Loading and Rewriting the Entire State Layer

Sixty Seconds of Silence
A new player connects their wallet and hits Play. What follows is a full minute of nothing. Just a loading bar and no feedback of any kind. Behind the curtain, hundreds of spritesheets, tilemaps, and audio files are downloading one by one in a neat little queue, each politely waiting for the previous one to finish before it even starts. Meanwhile, equipped gear pops onto the character seconds after the sprite appears, scene transitions cause a jarring freeze-then-flicker, and refreshing the page punishes you with the exact same sixty-second wait all over again because nothing was cached.
That was Edenverse two weeks ago. We knew it was bad. We talked about fixing it eventually. Then we actually sat down, opened the profiler, and the numbers hit harder than any boss fight we have designed. Sixty seconds of dead air on first load. Zero browser caching. A React Context architecture so tangled that equipping a hat re-rendered the quest panel, the minimap, and the chat window simultaneously. Something had to give, and it turned out to be the entire client architecture. Over two relentless weeks and roughly 960 commits, we tore out the foundation and rebuilt it from scratch. This is the story of what broke, why it broke, and exactly how we fixed it.
Ripping Out React Context
Let us talk about the biggest gamble first. We deleted every single React Context provider in the game and replaced the entire state management layer with Redux Toolkit and RTK Query. Not a gradual migration. Not a wrapper. A full scorched-earth rewrite where every shared value in the application: wallet address, scene key, equipped gear, quest progress, inventory, modal flags, moved into typed Redux slices with dedicated selectors.
graph TB
subgraph Before["Before: React Context Sprawl"]
direction TB
A1[App Component] --> B1[WalletContextProvider]
B1 --> C1[GameContextProvider]
C1 --> D1[UIContextProvider]
D1 --> E1[InventoryContextProvider]
E1 --> F1[QuestContextProvider]
F1 --> G1["Every Child Re-renders on ANY State Change"]
style G1 fill:#ef4444,color:#fff,stroke:#ef4444
end
subgraph After["After: Redux Toolkit Slices"]
direction TB
A2[App Component] --> B2[Redux Provider]
B2 --> C2[appSlice: wallet, connection]
B2 --> D2[gameSlice: scene, zoom, flags]
B2 --> E2[gameDataSlice: inventory, quests]
B2 --> F2["Components Subscribe to ONLY What They Need"]
style F2 fill:#10b981,color:#fff,stroke:#10b981
end
style Before fill:#1e293b,color:#f8fafc
style After fill:#0f2a1e,color:#f8fafc
Why go this far? Because React Context has a nasty habit that most tutorials never warn you about: when any value inside a Context changes, every component consuming that Context re-renders, regardless of whether it actually cares about the value that changed. In a dashboard app, you might never notice. In a game where player position, health, equipped tool, active animation, and scene key are all changing multiple times per second, it turns your entire UI into a cascade of wasted renders. Opening the inventory would tank the frame rate. Swapping a pickaxe would cause the quest log to blink. Transitioning between scenes would force literally every mounted component to tear down and rebuild itself at the same time. Redux Toolkit kills this problem at the root with selector-based subscriptions: each component declares exactly which slice of state it cares about, and React does not even bother re-rendering if that specific slice stayed the same. The frame drops we used to see during UI interactions went from 15-20 FPS dips to virtually zero.
We also drew a hard line on type safety during this rewrite. Every slice, every action payload, every selector return type is backed by an explicit TypeScript interface. The old codebase had accumulated dozens of silent runtime casts that let type errors slip past the compiler and surface as runtime crashes in production. Those days are over: if it does not type-check, it does not ship.
graph LR
subgraph Slices["Redux Slice Architecture"]
direction TB
AS["appSlice walletAddress isConnected identityToken"]
GS["gameSlice currentSceneKey isSceneSwitching sceneReadyVersion zoomLevel"]
GDS["gameDataSlice localInventory equippedTools questProgress"]
end
subgraph Selectors["Typed Selectors"]
S1["selectCurrentScene()"]
S2["selectWalletAddress()"]
S3["selectEquippedGear()"]
S4["selectIsSceneSwitching()"]
end
AS --> S1
AS --> S2
GS --> S1
GS --> S4
GDS --> S3
style Slices fill:#1e293b,color:#f8fafc
style Selectors fill:#0f2a1e,color:#f8fafc
From Waterfall to Parallel: 60 Seconds Down to 6
Here is how the old loader worked. It grabbed the forest tileset. Waited. Grabbed the snow tileset. Waited. Grabbed the desert tileset. Waited. Then the player spritesheet. Then each NPC. Then each audio track. Then the UI textures. Every single asset stood in line and waited its turn like it was buying coffee. The total load time was the sum of every individual download, and on a flaky mobile connection that sum ballooned to a full minute.
gantt
title Before: Sequential Asset Loading (Waterfall)
dateFormat YYYY-MM-DD HH:mm:ss
axisFormat %S s
section Tilesets
Forest Tileset :a1, 2026-01-01 00:00:00, 8s
Snow Tileset :a2, after a1, 7s
Desert Tileset :a3, after a2, 6s
section Sprites
Player Spritesheet :b1, after a3, 5s
NPC Spritesheets :b2, after b1, 6s
section Audio
Music Tracks :c1, after b2, 8s
SFX Files :c2, after c1, 5s
section UI
UI Textures :d1, after c2, 4s
Portal Animations :d2, after d1, 3s
We blew up the queue and replaced it with a three-phase parallel loader. Phase 1 fires off Promise.all for everything the player needs to see a playable screen: the active scene tilemap, the player spritesheet, equipped gear textures, and core UI chrome. That batch finishes in roughly 2 seconds and the player is already walking around. Phase 2 kicks in while the player explores, quietly pulling in neighboring scene tilemaps, NPC spritesheets, and portal animations in the background. Phase 3 picks up the rest: music, ambient sound, distant scenes, lazily during idle frames. The net result is a 10x improvement: what used to take 60 seconds now takes about 6.
gantt
title After: Parallel Phased Asset Loading
dateFormat YYYY-MM-DD HH:mm:ss
axisFormat %S s
section Phase 1: Critical
Active Scene Tilemap :a1, 2026-01-01 00:00:00, 2s
Player Spritesheet :a2, 2026-01-01 00:00:00, 1s
Equipped Gear Textures :a3, 2026-01-01 00:00:00, 2s
Core UI Elements :a4, 2026-01-01 00:00:00, 1s
section Phase 2: Secondary
Other Scene Tilemaps :b1, 2026-01-01 00:00:02, 3s
NPC Spritesheets :b2, 2026-01-01 00:00:02, 2s
Portal Animations :b3, 2026-01-01 00:00:02, 2s
section Phase 3: Lazy
Music Tracks :c1, 2026-01-01 00:00:05, 3s
Ambient SFX :c2, 2026-01-01 00:00:05, 2s
Distant Scene Assets :c3, 2026-01-01 00:00:05, 3s
Download Once, Play Forever
Shaving the first load from 60 seconds to 6 felt great until we realized that hitting F5 reset the clock back to zero. The browser had no reason to cache anything because we never told it to. The Vite build produced generic chunk names without content hashes, the deployment server sent default cache headers, and there was no local persistence of game state whatsoever. Every refresh re-downloaded every byte from the CDN and re-fetched every row from SpacetimeDB. On a 5G connection, that meant another 60-second penalty just because someone's browser tab reloaded.
The fix has three layers. First, we reconfigured the Vite build pipeline to produce content-hashed filenames for every JavaScript chunk, CSS bundle, and static asset, which lets the browser's native HTTP cache store them indefinitely without risking stale versions. Second, we set up aggressive Cache-Control headers on the deployment server so that returning visitors load the entire game client from local disk in under 2 seconds. Third, we built a lightweight persistence layer on top of IndexedDB that snapshots the player's last known game state: scene, position, inventory, equipped gear, and renders it instantly on page load while fresh subscription data streams in from SpacetimeDB behind the scenes. The returning player experience dropped from 60 seconds to roughly 6.
flowchart TB
A[Player Opens Game] --> B{First Visit?}
B -->|Yes| C[Download All Assets from CDN]
C --> D[Browser Caches with Content Hashes]
D --> E[Store Game State in IndexedDB]
E --> F[Game Ready in ~6s]
B -->|No| G[Load Assets from Browser Cache]
G --> H[Load Game State from IndexedDB]
H --> I[Render Immediately with Cached State]
I --> J[Stream Fresh Data from SpacetimeDB]
J --> K[Game Ready in ~2s]
style C fill:#f59e0b,color:#fff
style G fill:#10b981,color:#fff
style F fill:#06b6d4,color:#fff
style K fill:#06b6d4,color:#fff
When Fast Data Becomes a Problem
SpacetimeDB pushes data incredibly fast. We wrote a whole post about why we chose it. But speed creates its own kind of headache when all your database subscriptions: users, inventory, quests, gear, agents, positions, fire their initial payloads at the exact same moment. Hundreds of rows slam into the React tree simultaneously, React tries to reconcile them all in one render cycle, and for 2–3 brutal seconds the main thread locks up and the game freezes. You have a fast backend delivering data to a client that chokes on receiving it. Ironic.
We broke the firehose into a drip feed. Subscription data now flows through a priority queue that distributes updates across multiple requestAnimationFrame ticks instead of cramming everything into one synchronous render. The player's own position and gear render on the very first frame: that is non-negotiable. Other players and NPCs populate over the next 2–3 frames. Inventory, crafting recipes, historical quest logs, and everything else trickles in over the following 5–10 frames without ever stealing enough time from the main thread to cause a visible hitch. What used to be a hard freeze is now a smooth, almost cinematic fade-in where the world assembles itself piece by piece at a rock-solid 60 FPS.
flowchart LR
subgraph Old["Before: All At Once"]
direction TB
S1[All Subscriptions Fire] --> R1[Single Massive Render]
R1 --> F1["3-Second Freeze"]
end
subgraph New["After: Progressive Batching"]
direction TB
S2[Subscriptions Fire] --> P1["Frame 1: Player + Gear"]
P1 --> P2["Frame 2-3: Other Players + NPCs"]
P2 --> P3["Frame 4-10: Inventory + Quests + History"]
P3 --> F2["Smooth 60 FPS Throughout"]
end
style Old fill:#1e293b,color:#f8fafc
style New fill:#0f2a1e,color:#f8fafc
style F1 fill:#ef4444,color:#fff
style F2 fill:#10b981,color:#fff
By the Numbers
Two weeks. 960 commits. Zero shortcuts.
| Metric | Before v0.7.0 | After v0.7.0 | Improvement |
|---|---|---|---|
| Initial Load Time | ~60 seconds | ~6 seconds | 10x faster |
| Returning Player Load | ~60 seconds | ~2 seconds | 30x faster |
| State Management | React Context (re-renders everything) | Redux Toolkit (surgical selectors) | Zero wasted renders |
| Asset Loading Strategy | Sequential waterfall | Parallel phased loading | Concurrent by default |
| Asset Caching | None | Content-hashed + IndexedDB | Download once, cache forever |
What v0.7.0 Really Is
This version does not add a single new biome, boss, weapon, or quest. There is nothing flashy in the patch notes. No new pixel art to show off. What v0.7.0 delivers is harder to photograph but impossible to miss: the game just feels right now. It loads before you have time to check your phone.
None of this happened by guessing. We profiled every frame, traced every subscription, logged every asset download, and let the data tell us where the seconds were hiding. 960 commits later, the engine is cleaner than it has ever been and the game loads 10x faster. The best compliment we can hope for is that nobody notices the change at all: they just play, and nothing gets in the way.
Comments (0)
No comments yet. Be the first to comment!