Skip to content

Scene documents

A scene document is a *.scene.json file that describes ECS entities as data. Load one into a World, run it in an app, validate it, and save a world back to one.

modules: com.awakekt.awake.scene:document · com.awakekt.awake.scene:runtime Desktop · Android · iOS · Web

A scene document and the scene DSL are two ways to describe the same ECS content, and AwakeKt Studio saves scene documents. awake:scene:document owns the format and SceneLoader; awake:scene:runtime turns a document into entities and runs it. Both come with com.awakekt.awake.scene:authoring.

Write a scene document

A document has a schema version, an optional name, and a list of nodes. Each node becomes one entity with a Name and a Transform; its components are listed by component id, and its children are child entities.

harbor-town.scene.json
{
  "version": 1,
  "name": "Harbor Town",
  "nodes": [
    {
      "name": "dock",
      "transform": {
        "position": { "x": 0.0, "y": 0.0, "z": 4.0 },
        "scale": { "x": 6.0, "y": 0.2, "z": 2.0 }
      }
    },
    {
      "name": "lighthouse",
      "transform": {
        "position": { "x": 3.0, "y": 0.0, "z": -2.0 }
      },
      "children": [
        {
          "name": "beacon",
          "transform": {
            "position": { "x": 0.0, "y": 6.0, "z": 0.0 }
          },
          "components": [
            { "component": "spin_control", "speed": 0.5 }
          ]
        }
      ]
    }
  ]
}
Kotlin
world.scene {
    entity("dock") {
        transform(z = 4f, sx = 6f, sy = 0.2f, sz = 2f)
    }
    entity("lighthouse") {
        transform(x = 3f, z = -2f)
        entity("beacon") {
            transform(y = 6f)
            with(SpinControl().apply { speed = 0.5f })
        }
    }
}
  1. File > Open Scene... opens a scene document; File > New Scene starts an empty one.
  2. Edit entities in the Hierarchy and Inspector.
  3. File > Save Scene or File > Save Scene As... writes the scene document.

Studio validates the scene before it saves. If a rule is broken it shows a Scene Not Saved message with the first problem and its node, and writes nothing.

Every key, type and default is in the scene document schema, and every component id in scene document components.

Load it

Install the built-in components, decode the text, then instantiate it. instantiate checks the document first and creates a new World unless you pass one.

Kotlin
DefaultSceneComponentResolvers.install()
val document = SceneLoader.decode(json)
val scene = SceneLoader.instantiate(document)

val lighthouse = scene.roots.first { it.name == "lighthouse" }
val beacon = lighthouse.children.single().entity
val spin = scene.world.get<SpinControl>(beacon)

scene.roots mirrors the document's nodes, each with its entity and children, so you can find an entity by node name without a query.

Call Module What it does
SceneLoader.decode(text) document Parses JSON into a SceneDocument. Also decodeBytes.
SceneLoader.loadFromResource(path) document Suspends; reads a bundled resource and decodes it.
SceneLoader.load(path, source) document Suspends; reads through an AssetSource and decodes it.
SceneLoader.instantiate(document, world, registry) runtime Validates, then creates the entities. Returns a Scene.
document.instantiate(world, registry) runtime The same, as an extension on the document.
SceneLoader.fromWorld(world, name) runtime Builds a document from a live world.
SceneLoader.encode(document) document Writes JSON. Also encodeBytes.
SceneValidator.validate(document) document Returns every rule the document breaks.

Run a document in an app

Inside app { }, pass the decoded document to scene(document). The scene runtime installs the built-in components itself, creates the entities when the renderer is ready, and runs your systems over them:

Kotlin
val game = app {
    ecs {
        scene(SceneLoader.decode(json))
        frameSystem("beacon-spin") { BeaconSpin() }
        frameSystem("spin") { SpinSystem() }
    }
}

A mesh_renderer names its mesh and material; declare them with assets { mesh(…); material(…) } in the same block. The app's name for the scene is the document's name.

Replace the current scene

SceneManager owns one scene at a time in a World. switchTo destroys the current scene's entities before it loads the next, so there is no step to forget:

Kotlin
val world = World()
val scenes = SceneManager(world)
scenes.switchTo(SceneLoader.decode(json)) // Harbor Town's entities are now in world
scenes.switchTo(other) // Harbor Town is destroyed first, then other loads
scenes.close() // destroys whatever is current

Inside an app, the runtime's own manager is sceneManager, available in onReady and system factories.

switchTo keeps the systems the app registered. A project played with runProject goes to another of its scenes through its SceneSwitch instead, which also closes the old scene's systems and physics and plans the next scene's own: a system asks with services.sceneSwitch?.request("scenes/level-2.scene.json"), and a blueprint with its Load Scene node. See awake:project:runtime.

Save a world

fromWorld walks the entities that have a Transform, follows their parent links to rebuild the tree, and writes each component that has a registered binding:

Kotlin
val saved: SceneDocument = SceneLoader.fromWorld(scene.world, name = "Harbor Town")
val text = SceneLoader.encode(saved)

Pass extraComponents = { entity -> listOf(…) } to write components that have no binding of their own.

Components from other modules

DefaultSceneComponentResolvers.install() registers the ids Core knows about: camera, light, ambient_light, fog, skybox, mesh_renderer, pbr_material, spin_control, keyframe_animation, particle_emitter, terrain, canvas_element and prefab_link. custom always decodes. Other ids belong to the module that owns the component, and must be registered before a document that uses them is decoded:

Register with Module Ids
SceneComponentRegistry().registerControls() scene:controls movement_control, camera_rig, input_actions
SceneComponentRegistry.registerGlobal(binding) any that binding's id, for every load
loadProject(files, capabilities = …) project:runtime each scene capability's ids, for that project

A registry you build is passed to instantiate. Inside an app, where scene(document) uses the global registry, register with registerGlobal; Cameras and controls shows both.

Decoding refuses an id nothing registered. An editor or a tool that may not have a game's code, such as a validator or an asset pipeline, decodes with registry.sceneJson(keepUnknownComponents = true) instead, or SceneSerializers.createJson(keepUnknownComponents = true) without a registry. Each component it can't decode becomes a SceneUnknownComponent holding its JSON. The rest of the scene validates and instantiates; the unknown component runs nothing and waits on its entity as data, and fromWorld writes it back, so saving keeps it as it was. SceneValidator.unknownComponentIssues names each one. A game keeps the default, so a component it doesn't link still fails its load.

Validate a document

SceneValidator.validate returns every issue with the path of the node it is on. instantiate runs it first and throws SceneValidationException listing them all.

Kotlin
val document = SceneLoader.decode(
    """
    {
      "nodes": [
        { "name": "dock" },
        { "name": "dock", "components": [ { "component": "spin_control", "speed": -1.0 } ] }
      ]
    }
    """,
)
val issues = SceneValidator.validate(document)
issues.forEach { println("${it.path}: ${it.message}") }
Output
dock: duplicate node name 'dock' already used at dock
dock: spinControl.speed must not be negative

Node names must be unique across the whole document, because components such as camera_rig refer to nodes by name. A prefab instance is its own scope: the nodes a prefab_link brings in need unique names only within that instance, so one prefab can be placed many times. A link inside an instance resolves to the instance's own node first, then to the scopes around it.

How it works

Loading has three steps. decode parses the JSON and rewrites the old camelCase ids (meshRenderer, spinControl, pbrMaterial, prefabLink) to their snake_case form. instantiate validates the document, then walks the nodes: each becomes an entity with a Name and a Transform, parented to its node's parent, and each component id is handed to the binding registered for it, which adds the ECS component. Links to other nodes by name are resolved after every node exists.

A mesh_renderer is the exception: it does not create GPU resources while loading. It records a request, and the app's assets { } turns each request into a MeshRenderer.

An unregistered component id does not load

decode fails on a component id with no registered binding. Register the owning module's components first, as in the table above.

Some content does not survive fromWorld

fromWorld only writes entities that have a Transform, and only components whose binding can export them. mesh_renderer and custom components are not written back: pass them through extraComponents if you need them in the saved document.

A newer document is refused

A document whose version is higher than this engine's (SCENE_SCHEMA_VERSION, currently 1) throws SceneSchemaVersionException from decode.

Unknown data is kept, not applied

A custom component keeps its type and payload in the document, but no ECS component is added for it unless you register a resolver.

Prefabs

A node with prefab_link { path } places a prefab file there. Run withPrefabs on the decoded document before instantiate; loadProject does. Each file is read once, however many nodes link it, and a saved world keeps the link instead of a copy.

Debugging

Call SceneValidator.validate(document) and print each issue's path and message to see everything that would stop a document from loading. A custom component with no resolver logs a warning under the scene-binding logger and is skipped.

See also