logo

Babylon.js Market

By Lawrence

9 minutes

Scenes as Data

TL;DR — Write a whole level as JSONA plain-text format for structured data using braces and key-value pairs; here it describes a whole scene.: its entities, components, and starting values. Then boot it with ArcadeGame in four lines, instead of hand-wiring createEntity calls in TypeScript. The sceneA described world — its lights, camera, and objects — that the framework loads and renders; here written as a JSON file. is now content the framework reads, not code you compile. The bundle ships only the components a scene names. The rest of the course builds levels this way.

Last lesson, your systems stopped importing each other. They announce and request through the EventBus. They never hold a reference to whoever is listening. That freed the systems from each other. But the world you build around them is still hand-written TypeScript. For four lessons you built every world the same hard way. You wrote new World(), addSystem, createEntity, and entity.add, one call at a time. That taught you what a world is made of, but it locks your whole level into code. It breaks the moment you want a second level. A level designer can't touch it without recompiling. (Recompiling means rerunning the build that turns code into something the browser runs.)

Pac-Man shipped 256 levels. Nobody wrote 256 functions full of createEntity. The maze walls, the dot positions, the fruit schedule were all data. One copy of the code read that data. When the designers wanted a fresh maze for Ms. Pac-Man, nobody recompiled the ghost AI. Your components and systems are code. Your scenes should be content: a file anyone can diff (compare its versions), generate, share, or sell.

So what turns a JSON file into a live world? And why does a scene that never names Flipper never ship the flipper code at all?

Four lines to a running game

The @babylonjsmarket/arcade package ships ArcadeGame. This class turns scene JSON into a running world. The whole bootstrapThe small block of startup code that wires everything together and launches the app. is four lines. Two words show up in almost every block below. await means wait for this line to finish before the next one runs. canvas is the area on the web page Babylon draws into. It is an HTML <canvas> element you grab once.

import { ArcadeGame } from '@babylonjsmarket/arcade';
import { BabylonAdapter } from '@babylonjsmarket/ecs/babylon';

const game = new ArcadeGame(new BabylonAdapter());
await game.init(canvas, { clearColor: [0.08, 0.1, 0.18, 1] });
await game.loadSceneFromUrl('/scenes/playground.json');
game.start();

Everything you hand-wired in the first four lessons still happens. The JSON drives it now:

  • new ArcadeGame(new BabylonAdapter()) builds a World, an EventBus, and a SceneLoader inside. Hand it the Three adapter instead, and the same JSON renders in Three.js. The last lesson shows how.
  • game.init(canvas, { clearColor }) starts the rendererThe layer that draws the 3D scene to the screen each frame; here Babylon.js behind the adapter. on your canvas. clearColor is a renderer option. It is the [r, g, b, a] background the screen clears to each frame. You pass it here at init, not in the scene JSON.
  • game.loadSceneFromUrl('/scenes/playground.json') fetches the JSON. It imports and registers the components the scene names, then builds every entity. (A plain loadScene(sceneData) takes a scene object you already hold in memory.)
  • game.start() runs the render loop. It calls world.update(dt) every frame. game.stop() halts it.

ArcadeGame exposes game.world and game.eventBus as public properties, so nothing from before is closed off. You can still create entities, subscribe to events, and add systems by hand. The JSON layer is a convenience, and it doesn't lock anything away.

The structure of a scene file

The scene format is an interface in the ECS source, called SceneData. The JSON you write follows this shape. (In the types, a ? means the field is optional. : string and : number name the kind of value it holds.)

@babylonjsmarket/ecs/src/ECS/SceneLoader/SceneLoader.ts
export interface SceneData {
  /** Unique scene identifier */
  name: string;
  /** Display title for the game */
  gameTitle?: string;
  /** Display title for this scene/level */
  sceneTitle?: string;
  /** Mechanic guide slug — renders Examples/mechanics/{slug}.mdx in the side panel */
  guide?: string;
  /** Entity ID to use as the "world" entity (for global components) */
  worldEntity?: string;
  /** List of component types used (for pre-loading) */
  components?: string[];
  /** List of system types to create */
  systems?: string[];
  /** Entity definitions keyed by entity ID */
  entities: Record<string, EntityData>;
  /** Scene-wide configuration */
  config?: SceneConfig;
}

The two fields that carry a whole scene are name and entities. Here is a playground scene. It has a sky that tracks the sun, a ground plane that receives shadows, a hero sphere that casts a shadow, and an orbit camera that frames them all. Every color is an [r, g, b] array of floats from 0 to 1, with an optional 4th alpha. These are not the 0–255 or hex values a web page uses:

{
  "name": "Playground",
  "entities": {
    "Sky": {
      "tags": ["sky"],
      "components": {
        "Skybox": {
          "followLight": "sun",
          "zenithColor": [0.07, 0.1, 0.28],
          "horizonColor": [0.98, 0.55, 0.32],
          "groundColor": [0.06, 0.05, 0.09],
          "sunColor": [1.0, 0.85, 0.6],
          "sunGlowSize": 70
        }
      }
    },
    "Sun": {
      "tags": ["sun"],
      "components": {
        "DirectionalLight": {
          "direction": [-0.45, -0.25, -0.35],
          "intensity": 2.4,
          "diffuse": [1.0, 0.85, 0.65],
          "shadowEnabled": true,
          "shadowMapSize": 2048
        }
      }
    },
    "Ground": {
      "components": {
        "MeshPrimitive": {
          "primitive": "ground",
          "width": 200,
          "depth": 200,
          "color": [0.18, 0.16, 0.22]
        },
        "Shadow": { "castShadow": false, "receiveShadow": true }
      }
    },
    "Hero": {
      "components": {
        "MeshPrimitive": {
          "primitive": "sphere",
          "diameter": 2,
          "segments": 32,
          "position": [0, 1.2, 0],
          "material": { "diffuseColor": [0.25, 0.7, 0.95], "emissiveColor": [0.08, 0.22, 0.35] }
        },
        "Shadow": { "castShadow": true, "receiveShadow": false }
      }
    },
    "Camera": {
      "components": {
        "ArcCamera": { "target": "Hero", "distance": 12, "alpha": -1.2, "beta": 1.15 }
      }
    }
  }
}

entities is keyed by name, not by an array. (An array is a plain ordered list.) This lets the scene refer to things by name, with no hard pointer. (A hard pointer is a direct link to another object.) ArcCamera's "target": "Hero" names the Hero key. Sun is tagged "sun", so the Skybox's followLight: "sun" finds it. This is the same decoupling from Events, Not References, now written as data.

The Sun is a DirectionalLight. This is a light with parallel rays, like the sun. You aim it with a direction vector instead of placing it at a position. The Camera's ArcCamera orbits the Hero. alpha is the horizontal orbit angle and beta is the vertical one. Both are in radians, at the given distance.

Each value under components is that component's input data. Ground and Hero split the shadow work. The ground receives shadows but doesn't cast them. The hero casts a shadow but doesn't receive one. They use the same two components, just with opposite numbers. Shadows in 3D are computed per light. So each object opts in to casting, receiving, or both. The behavior lives in the components. The difference is all in the JSON.

Annotated scene JSON file on a dark background with neon callout lines: the entities object highlighted in cyan, an entity key labeled "becomes a live ECS entity", a components key labeled "component name maps to registry", and a value object labeled "constructor input data", dark navy background, green and magenta accent arrows, technical diagram style

A component value is a constructor argument

That "MeshPrimitive": { "primitive": "sphere", "diameter": 2, ... } block isn't a special scene language. It is the exact object the component's constructorThe setup code that builds a component from the input values — its arguments — you hand it. takes. (The constructor is the setup code that builds the component from the values you hand it. Those values are its arguments.) MeshPrimitive declares its accepted input as an interface. Every key you can write in the JSON is a field on it. (Again, ? marks an optional field, and : number names the kind of value.)

@babylonjsmarket/arcade/src/Components/MeshPrimitive/MeshPrimitive.core.ts
/** Input shape accepted by the component constructor (and the scene JSON). */
export interface MeshPrimitiveInput {
  primitive?: PrimitiveType;
  autoCreate?: boolean;

  /** [x, y, z]. Vector3-like objects are also accepted for legacy callers. */
  position?: Vec3 | { x: number; y: number; z: number };
  rotation?: Vec3 | { x: number; y: number; z: number };

  width?: number;
  height?: number;
  depth?: number;

  diameter?: number;
  segments?: number;

  diameterTop?: number;
  diameterBottom?: number;
  tessellation?: number;

  radius?: number;
  pivotAtBottom?: boolean;

  size?: number;
  subdivisions?: number;

  thickness?: number;

  /** Shorthand for diffuse color [r, g, b]. */
  color?: [number, number, number];

A sphere reads diameter and segments, and ignores width. A ground reads width and depth. The scene loader hands the JSON object straight to new MeshPrimitiveComponent(input). The component then picks the fields its primitive needs. Writing a scene file is calling these constructors. You just spell the arguments in JSON instead of TypeScript.

What loadScene actually does

ArcadeGame is thin. It owns a World, an EventBus, and a SceneLoader. The interesting part is the lazy component registryA lookup table mapping a component's name to the code that loads it.: a map from component names to dynamic imports. Here is the head of the real one. (Each () => import(...) is a small inline function, written with the => arrow. It does nothing until you call it.)

@babylonjsmarket/arcade/src/registry.ts
export const ARCADE_COMPONENT_REGISTRY: Record<string, LazyComponentResolver> = {
  MeshPrimitive: () => import("./Components/MeshPrimitive/MeshPrimitive"),
  Movement: () => import("./Components/Movement/Movement"),
  KeyboardMover: () => import("./Components/KeyboardMover/KeyboardMover"),
  PlayerInput: () => import("./Components/PlayerInput/PlayerInput"),
  ArcCamera: () => import("./Components/ArcCamera/ArcCamera"),
  CameraFollow: () => import("./Components/CameraFollow/CameraFollow"),
  DirectionalLight: () => import("./Components/DirectionalLight/DirectionalLight"),
  HemisphericLight: () => import("./Components/HemisphericLight/HemisphericLight"),
  Physics: () => import("./Components/Physics/Physics"),
  Score: () => import("./Components/Score/Score"),
  Scoreboard: () => import("./Components/Scoreboard/Scoreboard"),
  Health: () => import("./Components/Health/Health"),
  HealthBar: () => import("./Components/HealthBar/HealthBar"),
  Flash: () => import("./Components/Flash/Flash"),
  Shadow: () => import("./Components/Shadow/Shadow"),
  Skybox: () => import("./Components/Skybox/Skybox"),
  EnvironmentTexture: () => import("./Components/EnvironmentTexture/EnvironmentTexture"),
  Animation: () => import("./Components/Animation/Animation"),
  Mesh: () => import("./Components/Mesh/Mesh"),
  // Pinball — flipper-driven physics tables.
  Plunger: () => import("./Components/Plunger/Plunger"),

Each value is a function that returns a promiseA value that isn't ready yet but arrives later; code can await it to use the value once it lands. of the module. Nothing loads until the function is called. loadScene is what calls them. The first three lines hold the whole loading idea. Everything below the comment is internal bookkeeping about order. You can skim that part on a first read:

@babylonjsmarket/arcade/src/ArcadeGame.ts
async loadScene(sceneData: SceneJson): Promise<void> {
  const names = collectComponentNames(sceneData);
  await Promise.all(Array.from(names).map((name) => this.registerByName(name)));
  this.sceneLoader.loadSceneFromData(sceneData as Parameters<SceneLoader["loadSceneFromData"]>[0]);

  // Two-phase load so the order is correct regardless of how the scene JSON
  // orders its entities/systems:
  //
  //   1. Create + add + INITIALIZE every system the scene uses, WITHOUT
  //      creating any entities yet (`createEntities: false`). Adding each
  //      system to an initialized world subscribes it to the EventBus
  //      (its `onInitialize` runs).
  //   2. Instantiate the entities (`createSystems: false`, since the systems
  //      already exist). Creating an entity fires every system's
  //      `onEntityAdded`; because all systems are already subscribed, a
  //      one-shot event emitted from one system's `onEntityAdded` (e.g. the
  //      arcade DirectionalLight's `directionallight.created`, which carries
  //      the light handle that CardFan/PokerTableCards/Shadow need) reliably
  //      reaches every other system.
  //
  // The previous order (instantiate entities, THEN addSystem each) lost those
  // one-shot events: entities existed before any system was subscribed, so the
  // member had to manually call `world.initialize()` first and order the
  // card-renderer entity ahead of the light. This makes it just work.
  const { systems } = this.sceneLoader.instantiateScene(sceneData.name, this.world, {
    createEntities: false,
  });
  for (const system of systems) {
    this.world.addSystem(system);
  }

  // Ensure the world (and therefore every system just added) is initialized
  // and subscribed BEFORE any entity is created. addSystem only initializes a
  // system immediately when the world is already initialized; for the first
  // scene the world is not yet initialized, so initialize it now.
  this.world.initialize();

  this.sceneLoader.instantiateScene(sceneData.name, this.world, {
    createSystems: false,
  });
}

Here is what those three lines do. collectComponentNames walks the JSON and gathers the names used. The playground scene names five: Skybox, DirectionalLight, MeshPrimitive, Shadow, and ArcCamera. The Promise.all calls each name's resolverA function the registry stores that, when called, loads and returns a component's module., so those five modules download at the same time. Each module exports a {Name}Component and usually a {Name}System. The SceneLoader registers both. The naming convention matters here, because the loader finds the exports by string. The two instantiateScene calls run systems first, then entities. So a one-shot event a system emits as an entity is created reaches every other system that needs it.

The payoff is in line three. A bundlerA build tool (Vite, Rollup, esbuild) that combines your modules into the files a browser downloads. turns each import() into its own chunkOne of the separate files a bundler produces when it splits your code.. That chunk downloads only when its resolver runs. So your shipped bundle carries only the components your scenes name. If no scene names Flipper, the flipper code never ships. You get per-component code splittingBreaking a bundle into separate files that download only when the code in them is actually needed., with no bundler configuration to write.

Flow diagram on dark background showing a scene JSON document node feeding into a lazy registry box of component-name keys, with only three of many dotted module nodes lighting up in neon green as dynamic imports, flowing right into a SceneLoader register step and finally a World containing glowing entity nodes, neon cyan and magenta lines on near-black, labeled stages: collect names, dynamic import, register pairs, instantiate entities

One failure mode is worth knowing now. Name a component the registry doesn't have, and ArcadeGame warns and skips it. The scene still loads, but that entity is missing a part. Watch the console when a mesh you expected doesn't appear.

The built-in arcade component library

Naming components in JSON only pays off if there are components worth naming. The arcade registry maps about forty of them. Knowing what already exists lets you use it instead of rebuilding it:

GroupComponents
RenderingMeshPrimitive (boxes, spheres, capsules, grounds), Mesh (load .glb/.gltf), Shadow, Skybox, EnvironmentTexture, DirectionalLight, HemisphericLight
InputKeyboardMover (W/A/S/D movement keys, working out of the box), PlayerInput (mapped actions), TwinStickShooter
Movement & physicsMovement (velocity-driven), Physics (rigid bodies — collidable solid objects)
CamerasArcCamera (orbit a target), CameraFollow (track with lag), PinballCamera, ShooterCamera
GameplayScore, Scoreboard (DOM overlay), Health, HealthBar, Flash (hit feedback)
AnimationAnimation, SkeletonAnimator
Pooling & shooterEntityPool, BulletPool, Bullet, Enemy, EnemySpawner
PinballPinballTable, Flipper, Plunger, Bumper, Spinner, MouseHole

Add a KeyboardMover next to a MeshPrimitive, and the mesh drives with arrow keys. EnemySpawner plus EntityPool gives you the backbone of a wave shooter. Flipper, Plunger, and Bumper combine into a working pinball table.

Every one follows the convention you've used since Lesson 2. {Name}Component holds data. {Name}System holds logic. {Name}Events names what it emits. {Name}InputEvents names what it listens to. The library isn't a different kind of code from yours. It is yours, published.

Registering your own components

To use the Spin component you wrote earlier in scene JSON, register a resolver. Do it in the constructor:

const game = new ArcadeGame(new BabylonAdapter(), {
  componentRegistry: {
    Spin: () => import('./components/Spin'),
  },
});

Or after construction:

game.addComponentResolver('Spin', () => import('./components/Spin'));

The contract is the naming convention. Your ./components/Spin module must export SpinComponent. If it has behavior, export a SpinSystem too. The system is added to the world once during scene load, before its entities are created. Do that, and this works in any scene file:

"Hero": {
  "components": {
    "MeshPrimitive": { "primitive": "sphere", "diameter": 2 },
    "Spin": { "axis": "y", "speed": 1.5 }
  }
}

Your component now sits in the JSON next to the built-ins, and looks just like them. Custom resolvers merge over the built-in registry. So registering an existing name overrides the stock component. You can change KeyboardMover's behavior without forking the package. Here is how a name resolves:

Name in JSONIn built-in registry?In your registry?What loads
MeshPrimitiveyesnothe arcade built-in
Spinnoyesyour module
KeyboardMoveryesyesyours — custom overrides the built-in
Wobblenononothing; ArcadeGame warns and skips it

Diagram of two registry boxes merging on a dark background: a large built-in arcade registry box with many component name entries in dim cyan, and a small user registry box with a glowing magenta Spin entry, arrows merging both into a single resolver map feeding an ArcadeGame node, neon outlines on near-black, clean technical style

Your turn: edit the scene

In a Vite project, public/ is the folder of static files served as-is at the site root. Save the playground JSON there as public/scenes/playground.json. Point loadSceneFromUrl at /scenes/playground.json. (The leading / is that root.) Then make three edits:

  1. Reshape the hero. Change its MeshPrimitive to "primitive": "capsule" with a "height": 2, and drop diameter and segments. No import changed. MeshPrimitive was already in the bundle, and capsule just reads different fields off the same input interface.
  2. Add a rim light. Create an entity Rim with a DirectionalLight pointing opposite Sun. Use "direction": [0.45, -0.25, 0.35], a low "intensity": 0.6, a cool "diffuse": [0.4, 0.5, 1.0], and leave shadowEnabled off. A rim light hits the subject from behind. It traces the edges and lifts the subject off the background. This is classic key-and-rim lighting, in pure data.
  3. Retint the sky. The Skybox gradient runs zenithColor overhead, horizonColor at the sun line, and groundColor below. Push horizonColor toward [0.9, 0.3, 0.5] for a magenta sunset. Raise sunGlowSize to make the sun's disk bloom.

Save and reload after each edit. Every edit changed data, not code. The world rebuilds from the new JSON, while the systems and components stay exactly as they were.

Notice what the scene doesn't declare: no KeyboardMover, no Movement, no Score. It's a diorama, not a game. The format can name gameplay components. But knowing how they work together is its own skill. That's the skill the next lesson builds.

Next: Composing Gameplay — wiring KeyboardMover, Movement, Score, and Health into a playable coin collector, with one small system of glue code that makes it your game.

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search