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.
{
"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 }
]
}
]
}
]
}
- File > Open Scene... opens a scene document; File > New Scene starts an empty one.
- Edit entities in the Hierarchy and Inspector.
- 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.
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:
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:
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. A game that shows a loading curtain or an error reads the
app's SceneSwitchStatus service: its state is idle, loading with the share of the next scene's
bytes that have arrived, ready once the new scene has had its first frame, or failed with the scene's
path and cause. See
awake:project:runtime.
Get a node ready before it spawns
spawn builds a node's meshes the first time it draws them, and a texture a resolver loads from
files draws a frame or more later. For something that must look right the moment it appears, such as
a character arriving or a short effect, prewarm it first. prewarm lists what the node and its
children draw (node.assetReferences()), then each frame builds and uploads its meshes and
materials and loads its textures off the frame:
Once it is ready, a spawn draws the node on its first frame and shares what the prewarm built. The prewarm holds those assets so nothing frees them in the meantime; close it after the spawn:
SceneAssetLibrary.prewarmFrameBudgetBytes caps the new mesh uploaded per frame, so prewarming
while the game plays doesn't stall a frame. An asset that can't be built is listed in failed, and
is built when it is first drawn instead. Closing a prewarm before it is ready, or tearing the scene
down, stops it, and await then throws CancellationException. A ready prewarm still open at teardown
held its assets for the whole scene, so teardown logs a warning naming them. Expand prefab links first
(withPrefabs): assetReferences lists a link it can't follow in prefabLinks.
A glTF model the scene's resolver hasn't loaded is read and decoded off the frame thread in the
session's coroutineScope, then built like the rest. Prewarms and a project spawn of the same model
share one load, and closing every one of them, or tearing the scene down, cancels it. In a played
project, a node whose models a prewarm loaded draws them on the frame it spawns; a model that fails to
load is listed in failed. A resolver of your own joins in by overriding SceneAssetResolver.loadMesh
for a mesh it has to load before it can build it.
A prewarm also decodes the node's audio_source clips, off the frame and without playing them, and
reads the images its 3D particle_emitters show. These don't go through the library's textures: they
go into the scene's own clips and particle images, the same ones its AudioSystem and
ParticleContentSystem use, which the host gives the library as SceneAssetLibrary.audioClips and
SceneAssetLibrary.particleImages. runProject does that for each scene it plays, and closes them
when the scene stops; a host that runs the scene's systems itself does it like this:
// One set of clips for the scene: its AudioSystem plays them, and a prewarm decodes into them.
private val clips = audioClipLoads(files, game.session.coroutineScope).also { library.audioClips = it }
private val audio = AudioSystem(player, clips)
Each clip and image decodes once, whether the prewarm or the system asks first, and the prewarm holds
it until it is closed, so the spawn's first frame plays the clip and draws the emitter. Closing the
prewarm, or tearing the scene down, cancels a decode nothing else waits for. A clip that isn't a 16-bit
PCM WAV file, an image that can't be read, or a scene that gave the library no clips or particle images
is listed in failed, by path. particleImageLoads(files, session.coroutineScope) makes the particle
images as audioClipLoads(files, session.coroutineScope) makes the clips: a decode nobody gave a scope
of its own runs in the scene session's, so disposing the session stops it.
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:
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 scene module's own components are listed in the registry the build generates for it, such as
SceneCoreSceneComponents; register one with registry.register(module). Scene components
shows how to add a component to one.
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.
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}") }
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
- Scene document schema for every key.
- Scene document components for every component id.
- Scene DSL for the same content in Kotlin.
- Load a scene document, the tutorial.