ArcadeGame is a thin convenience class around World + SceneLoader + EventBus. It does three things you'd otherwise wire by hand:
- Constructs the World/SceneLoader/EventBus as a coherent set
- Lazy-imports components referenced by your scene JSON
- Auto-registers the imported classes with the SceneLoader
If you want to know exactly what's happening when you call loadSceneFromUrl, this section walks through the actual implementation.
The constructor
Three things to notice:
- A single shared
EventBusis threaded through World and SceneLoader. They both emit on it; your custom Systems all use it too. - The
Worldis constructed with the renderer you pass in — nothing about ArcadeGame is renderer-specific. resolversmerges the built-in 14-component registry with any custom resolvers fromoptions.componentRegistry. We'll see how it's used below.
init
Pass-through to the adapter. The adapter handles WebGL setup, camera defaults, lighting defaults, etc. ArcadeGame doesn't intervene.
loadSceneFromUrl
Just fetch + json + loadScene. No custom transport, no caching. If you want IndexedDB, custom headers, or bundled scenes, call loadScene(data) directly with your own data source.
loadScene — the interesting part
Five steps:
collectComponentNames— walkssceneData.entities, grabs every key from everycomponentsobject. Returns aSet<string>of unique names.Promise.all(... registerByName ...)— imports each module in parallel. If the same scene uses MeshPrimitive 5 times, the import happens once.sceneLoader.loadSceneFromData— parses the JSON into the underlying SceneLoader's internal store. Doesn't create entities yet.sceneLoader.instantiateScene— actually creates the entities, attaches components, returns the list of auto-created Systems.world.addSystem(system)— wires each System into the World. From now on,world.update(dt)ticks them.
After step 5, the World has live entities, live Systems, and is ready to render.
registerByName — the lazy import
A scene referencing "MeshPrimitive" causes:
This is the whole magic. The bundler statically analyzes the () => import('./...') arrow and splits each component into its own chunk. Loading a scene that uses 5 of the 14 components pulls only 5 chunks across the wire.
The registry
It's a static object. Each value is a thunk. Bundlers handle the dynamic-import-with-literal-specifier pattern correctly — chunk per component.
Mixing in your own components
Pass extra resolvers via the constructor. They merge with the built-in 14. Your scene JSON can now reference "EnemySpawner" and "Health" like any built-in component.
Or add resolvers after construction:
The convention is enforced loosely: the imported module must export <Name>Component (required) and <Name>System (optional). If the System export is missing, the SceneLoader just registers the Component class — useful for data-only components.
start and stop
Idempotent. start ticks the World via the renderer's frame ticker. stop halts the loop.
What ArcadeGame doesn't do
- Doesn't dispose the renderer — call
this.renderer.dispose()yourself when you're done. - Doesn't tear down the World — you can
stop()andstart()on the same instance, but if you want a fully clean state, construct a newArcadeGame. - Doesn't handle scene transitions — to swap scenes,
stop(), throw the instance away, build a new one. Or wire your own transition logic at the World level.
Where to next
- MeshPrimitive — the most-used component
- Extending — custom registries, viz panels, going deeper