Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export

This commit is contained in:
Emil
2026-09-18 03:40:15 +03:00
parent 5c6b24d34d
commit 999686a896
125 changed files with 16086 additions and 1714 deletions
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace beacon {
inline nlohmann::json schema() {
return {{"id", "example.beacon"},
{"name", "Beacon"},
{"version", 1},
{"fields",
{{"speed",
{{"id", "speed"},
{"name", "Rotation speed"},
{"type", "number"},
{"default", 1.0},
{"units", "rad/s"}}}}}};
}
inline void register_behavior(faset::runtime::Runtime& runtime) {
faset::runtime::Behavior behavior;
behavior.fixedUpdate = [](faset::runtime::Runtime& world, faset::runtime::EntityHandle self,
double dt) {
auto pose = world.transform(self);
pose.rotation[1] +=
world.fields(self, "example.beacon").value("speed", 1.0f) * static_cast<float>(dt);
world.setTransform(self, pose);
};
runtime.registerBehavior("example.beacon", std::move(behavior));
}
} // namespace beacon
+119
View File
@@ -0,0 +1,119 @@
#include <array>
#include <faset/editor/plugin_api.h>
#include <iomanip>
#include <nlohmann/json.hpp>
#include <random>
#include <sstream>
#include <stdexcept>
namespace {
using Json = nlohmann::json;
struct State {
const FasetEditorHost* host;
};
void collect(void* target, const char* data, uint64_t size) {
static_cast<std::string*>(target)->append(data, size);
}
Json invoke(State& state, const char* command, const Json& arguments) {
std::string output;
const auto input = arguments.dump();
const auto status =
state.host->invoke_command(state.host->context, command, input.c_str(), collect, &output);
auto result = Json::parse(output);
if (status != 0)
throw std::runtime_error(result.value("message", std::string("Editor command failed")));
return result;
}
std::string id() {
std::random_device random;
std::ostringstream stream;
stream << std::hex << std::setfill('0');
for (int i = 0; i < 4; ++i)
stream << std::setw(8) << random();
return stream.str();
}
int create(void* user, const char* input, FasetWrite write, void* receiver) {
try {
auto& state = *static_cast<State*>(user);
const auto arguments = Json::parse(input);
const auto document =
invoke(state, "faset_document_query", {{"document", arguments.at("document")}});
const auto schema = invoke(state, "faset_schema", Json::object());
bool known = false;
for (const auto& type : schema.at("types"))
if (type.at("id") == "example.beacon")
known = true;
if (!known)
throw std::runtime_error("Include Beacon.hpp in gameplay, register its schema and "
"behavior, then Build before adding a Beacon");
const auto entity = id();
Json record = {
{"id", entity},
{"name", "Beacon"},
{"parent", nullptr},
{"components",
Json::array(
{{{"id", id()},
{"type", "faset.transform"},
{"version", 1},
{"fields",
{{"position", {0, 1, 0}}, {"rotation", {0, 0, 0}}, {"scale", {1, 1, 1}}}}},
{{"id", id()},
{"type", "faset.mesh"},
{"version", 1},
{"fields",
{{"asset", ""}, {"primitive", "cube"}, {"color", {0.68, 0.55, 0.95, 1}}}}},
{{"id", id()},
{"type", "example.beacon"},
{"version", 1},
{"fields", {{"speed", 1.0}}}}})}};
const auto result =
invoke(state, "faset_scene_edit",
{{"document", document.at("id")},
{"revision", document.at("revision")},
{"operations", Json::array({{{"op", "entity.create"}, {"entity", record}}})}})
.dump();
write(receiver, result.data(), result.size());
return 0;
} catch (const std::exception& error) {
const auto output = Json{{"code", "beacon.create"}, {"message", error.what()}}.dump();
write(receiver, output.data(), output.size());
return 1;
}
}
void shutdown(void* user) {
delete static_cast<State*>(user);
}
} // namespace
extern "C" FASET_PLUGIN_EXPORT int faset_editor_plugin(const FasetEditorHost* host,
FasetEditorPlugin* plugin) {
if (!host || !plugin || host->api_version != FASET_EDITOR_API_VERSION ||
host->struct_size != sizeof(FasetEditorHost) ||
std::string(host->build_fingerprint) != FASET_EDITOR_SDK_FINGERPRINT)
return 1;
try {
auto* state = new State{host};
*plugin = {FASET_EDITOR_API_VERSION, sizeof(FasetEditorPlugin),
FASET_EDITOR_SDK_FINGERPRINT, state, shutdown};
const auto descriptor = Json{
{"name", "plugin_example_beacon_create"},
{"description", "Create a rotating Beacon using one authoring transaction."},
{"inputSchema",
{{"type", "object"},
{"properties", {{"document", {{"type", "string"}}}}},
{"required", {"document"}},
{"additionalProperties",
false}}}}.dump();
if (host->register_command(host->context, descriptor.c_str(), create, state) != 0)
return 1;
const auto panel = Json{{"id", "example.beacon.tools"},
{"title", "Beacon tools"},
{"action", "Add Beacon"},
{"command", "plugin_example_beacon_create"},
{"arguments", {{"document", "$document"}}}}
.dump();
return host->register_panel(host->context, panel.c_str());
} catch (...) {
return 1;
}
}
+52 -33
View File
@@ -6,46 +6,65 @@
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& engine) {
runtime::Behavior character;
character.fixedUpdate=[](runtime::Runtime& world,runtime::EntityHandle self,double) {
const auto fields=world.fields(self,"gameplay.character");
auto velocity=world.velocity(self);const auto input=world.input();
velocity[0]=input.horizontal*fields.value("speed",4.0f);
// A minimal demo controller: jump only near zero vertical velocity.
// A production grounded controller needs contact normals / a ground query.
if(input.jumpPressed&&std::abs(velocity[1])<0.1f)velocity[1]=fields.value("jump_speed",5.0f);
world.setVelocity(self,velocity);
character.fixedUpdate = [](runtime::Runtime& world, runtime::EntityHandle self, double) {
const auto fields = world.fields(self, "gameplay.character");
auto velocity = world.velocity(self);
const auto input = world.input();
velocity[0] = input.horizontal * fields.value("speed", 4.0f);
if (input.jumpPressed && world.grounded(self))
velocity[1] = fields.value("jump_speed", 5.0f);
world.setVelocity(self, velocity);
};
engine.registerBehavior("gameplay.character",std::move(character));
engine.registerBehavior("gameplay.character", std::move(character));
runtime::Behavior door;
auto open=std::make_shared<std::map<std::pair<std::uint64_t,std::uint32_t>,bool>>();
door.onStart=[open](runtime::Runtime&,runtime::EntityHandle self,double){(*open)[{self.session,self.slot}]=false;};
door.onDestroy=[open](runtime::Runtime&,runtime::EntityHandle self,double){open->erase({self.session,self.slot});};
door.fixedUpdate=[open](runtime::Runtime& world,runtime::EntityHandle self,double dt) {
const auto fields=world.fields(self,"gameplay.door");
auto pose=world.transform(self);
auto& opened=(*open)[{self.session,self.slot}];
if(world.input().interactPressed)opened=!opened;
const float target=opened?fields.value("open_angle",1.5707963f):fields.value("closed_angle",0.0f);
const float distance=target-pose.rotation[1];
const float amount=std::max(0.0f,fields.value("speed",1.5f))*static_cast<float>(dt);
pose.rotation[1]+=std::clamp(distance,-amount,amount);
world.setTransform(self,pose);
auto open = std::make_shared<std::map<std::pair<std::uint64_t, std::uint32_t>, bool>>();
door.onStart = [open](runtime::Runtime&, runtime::EntityHandle self, double) {
(*open)[{self.session, self.slot}] = false;
};
engine.registerBehavior("gameplay.door",std::move(door));
door.onDestroy = [open](runtime::Runtime&, runtime::EntityHandle self, double) {
open->erase({self.session, self.slot});
};
door.fixedUpdate = [open](runtime::Runtime& world, runtime::EntityHandle self, double dt) {
const auto fields = world.fields(self, "gameplay.door");
auto pose = world.transform(self);
auto& opened = (*open)[{self.session, self.slot}];
if (world.input().interactPressed)
opened = !opened;
const float target =
opened ? fields.value("open_angle", 1.5707963f) : fields.value("closed_angle", 0.0f);
const float distance = target - pose.rotation[1];
const float amount = std::max(0.0f, fields.value("speed", 1.5f)) * static_cast<float>(dt);
pose.rotation[1] += std::clamp(distance, -amount, amount);
world.setTransform(self, pose);
};
engine.registerBehavior("gameplay.door", std::move(door));
}
nlohmann::json schema() {
// Explicit declarations shared by Player and SchemaExporter. This function
// constructs descriptions only: no Runtime, physics world or lifecycle.
return nlohmann::json::array({
{{"id","gameplay.character"},{"version",1},{"name","Character"},{"fields",{
{"speed",{{"id","speed"},{"type","number"},{"default",4.0},{"min",0.0}}},
{"jump_speed",{{"id","jump_speed"},{"type","number"},{"default",5.0},{"min",0.0}}}}}},
{{"id","gameplay.door"},{"version",1},{"name","Door"},{"fields",{
{"open_angle",{{"id","open_angle"},{"type","number"},{"default",1.5707963},{"units","rad"}}},
{"closed_angle",{{"id","closed_angle"},{"type","number"},{"default",0.0},{"units","rad"}}},
{"speed",{{"id","speed"},{"type","number"},{"default",1.5},{"min",0.0},{"units","rad/s"}}}}}}
});
}
return nlohmann::json::array(
{{{"id", "gameplay.character"},
{"version", 1},
{"name", "Character"},
{"fields",
{{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 4.0}, {"min", 0.0}}},
{"jump_speed",
{{"id", "jump_speed"}, {"type", "number"}, {"default", 5.0}, {"min", 0.0}}}}}},
{{"id", "gameplay.door"},
{"version", 1},
{"name", "Door"},
{"fields",
{{"open_angle",
{{"id", "open_angle"}, {"type", "number"}, {"default", 1.5707963}, {"units", "rad"}}},
{"closed_angle",
{{"id", "closed_angle"}, {"type", "number"}, {"default", 0.0}, {"units", "rad"}}},
{"speed",
{{"id", "speed"},
{"type", "number"},
{"default", 1.5},
{"min", 0.0},
{"units", "rad/s"}}}}}}});
}
} // namespace faset::gameplay
+1 -1
View File
@@ -4,4 +4,4 @@
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& runtime);
nlohmann::json schema();
}
} // namespace faset::gameplay
+43
View File
@@ -0,0 +1,43 @@
#include "Gameplay.hpp"
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior motion;
motion.fixedUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double delta) {
auto pose = game.transform(self);
pose.position[0] += game.fields(self, "tutorial.fixed_move").value("speed", 2.0f) *
static_cast<float>(delta);
game.setTransform(self, pose);
};
world.registerBehavior("tutorial.fixed_move", std::move(motion));
runtime::Behavior follow;
follow.lateUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double) {
const auto settings = game.fields(self, "tutorial.follow");
const auto target = game.find(settings.value("target", std::string{}));
if (!game.valid(target))
return; // Target may be absent or destroyed.
const auto targetPose = game.presentation(target); // Already interpolated.
const auto offset = settings.at("offset").get<runtime::Vec3>();
auto cameraPose = game.presentation(self);
for (int axis = 0; axis < 3; ++axis)
cameraPose.position[axis] = targetPose.position[axis] + offset[axis];
game.setPresentation(self, cameraPose); // Does not write the simulation pose.
};
world.registerBehavior("tutorial.follow", std::move(follow));
}
nlohmann::json schema() {
return nlohmann::json::array(
{{{"id", "tutorial.fixed_move"},
{"version", 1},
{"name", "Fixed movement"},
{"fields", {{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 2.0}}}}}},
{{"id", "tutorial.follow"},
{"version", 1},
{"name", "Follow presentation"},
{"fields",
{{"target", {{"id", "target"}, {"type", "entity_ref"}, {"default", "actor"}}},
{"offset", {{"id", "offset"}, {"type", "vec3"}, {"default", {0, 0, 10}}}}}}}});
}
} // namespace faset::gameplay
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+126
View File
@@ -0,0 +1,126 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-following",
"name": "Following tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "actor",
"name": "actor",
"parent": null,
"components": [
{
"id": "actor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "actor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.7,
0.9,
1
],
"size": [
1,
1
]
}
},
{
"id": "actor-tutorial.fixed_move",
"type": "tutorial.fixed_move",
"version": 1,
"fields": {
"speed": 2
}
}
]
},
{
"id": "camera",
"name": "camera",
"parent": null,
"components": [
{
"id": "camera-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
10
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "camera-faset.camera",
"type": "faset.camera",
"version": 1,
"fields": {
"fov": 60,
"near": 0.1,
"far": 100
}
},
{
"id": "camera-tutorial.follow",
"type": "tutorial.follow",
"version": 1,
"fields": {
"target": "actor",
"offset": [
0,
0,
10
]
}
}
]
}
],
"instances": []
}
+31
View File
@@ -0,0 +1,31 @@
#include "Gameplay.hpp"
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior mover;
mover.update = [](runtime::Runtime& game, runtime::EntityHandle self, double delta) {
// fields() returns a configuration copy. transform() returns a live pose copy.
const auto settings = game.fields(self, "tutorial.move_x");
const float speed = settings.value("speed", 2.0f); // metres per second
auto pose = game.transform(self);
pose.position[0] += speed * static_cast<float>(delta);
game.setTransform(self, pose); // This object has no rigid body.
};
world.registerBehavior("tutorial.move_x", std::move(mover));
}
nlohmann::json schema() {
// The FieldId is the map key. Keep it stable if you change a display name.
return nlohmann::json::array({{{"id", "tutorial.move_x"},
{"version", 1},
{"name", "Move along X"},
{"fields",
{{"speed",
{{"id", "speed"},
{"type", "number"},
{"default", 2.0},
{"min", -20.0},
{"max", 20.0},
{"units", "m/s"}}}}}}});
}
} // namespace faset::gameplay
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+74
View File
@@ -0,0 +1,74 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-moving",
"name": "Moving tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "actor",
"name": "actor",
"parent": null,
"components": [
{
"id": "actor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "actor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.7,
0.9,
1
],
"size": [
1,
1
]
}
},
{
"id": "actor-tutorial.move_x",
"type": "tutorial.move_x",
"version": 1,
"fields": {
"speed": 2
}
}
]
}
],
"instances": []
}
+43
View File
@@ -0,0 +1,43 @@
#include "Gameplay.hpp"
#include <iostream>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior character;
character.fixedUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double) {
const auto settings = game.fields(self, "tutorial.character");
const auto input = game.input();
auto velocity = game.velocity(self); // Metres per second; preserve the Y component.
velocity[0] = input.horizontal * settings.value("speed", 4.0f);
if (input.jumpPressed && game.grounded(self))
velocity[1] = settings.value("jump_speed", 5.0f);
// Do not multiply velocity by delta. The physics solver integrates it.
game.setVelocity(self, velocity);
};
character.onCollision = [](runtime::Runtime&, runtime::EntityHandle,
const runtime::CollisionEvent& event) {
if (event.began)
std::cout << "Character contact began\n";
};
world.registerBehavior("tutorial.character", std::move(character));
}
nlohmann::json schema() {
return nlohmann::json::array({{{"id", "tutorial.character"},
{"version", 1},
{"name", "Physics character"},
{"fields",
{{"speed",
{{"id", "speed"},
{"type", "number"},
{"default", 4.0},
{"min", 0.0},
{"units", "m/s"}}},
{"jump_speed",
{{"id", "jump_speed"},
{"type", "number"},
{"default", 5.0},
{"min", 0.0},
{"units", "m/s"}}}}}}});
}
} // namespace faset::gameplay
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+148
View File
@@ -0,0 +1,148 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-physics",
"name": "Physics tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "floor",
"name": "floor",
"parent": null,
"components": [
{
"id": "floor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
-0.5,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "floor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.4,
0.5,
0.6,
1
],
"size": [
12,
1
]
}
},
{
"id": "floor-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "static",
"half_extents": [
6,
0.5
]
}
}
]
},
{
"id": "actor",
"name": "actor",
"parent": null,
"components": [
{
"id": "actor-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0.5,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "actor-faset.sprite",
"type": "faset.sprite",
"version": 1,
"fields": {
"color": [
0.2,
0.7,
0.9,
1
],
"size": [
1,
1
]
}
},
{
"id": "actor-faset.rigid_body_2d",
"type": "faset.rigid_body_2d",
"version": 1,
"fields": {
"body_type": "dynamic",
"half_extents": [
0.5,
0.5
],
"density": 1,
"friction": 0.3,
"gravity_scale": 1
}
},
{
"id": "actor-tutorial.character",
"type": "tutorial.character",
"version": 1,
"fields": {
"speed": 4,
"jump_speed": 5
}
}
]
}
],
"instances": []
}
+70
View File
@@ -0,0 +1,70 @@
#include "Gameplay.hpp"
#include <map>
#include <tuple>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world) {
runtime::Behavior spawner;
spawner.onStart = [](runtime::Runtime& game, runtime::EntityHandle self, double) {
const auto settings = game.fields(self, "tutorial.spawn_once");
const auto id = settings.at("spawned_id").get<std::string>();
// This queues a runtime object; it does not change the saved scene.
game.spawn(
{{"id", id},
{"name", "Temporary box"},
{"parent", nullptr},
{"components", nlohmann::json::array(
{{{"id", id + "/transform"},
{"type", "faset.transform"},
{"version", 1},
{"fields", {{"position", {0, 0, 0}}}}},
{{"id", id + "/sprite"},
{"type", "faset.sprite"},
{"version", 1},
{"fields", {{"color", {0.9, 0.6, 0.2, 1}}, {"size", {1, 1}}}}},
{{"id", id + "/lifetime"},
{"type", "tutorial.timed_despawn"},
{"version", 1},
{"fields", {{"seconds", settings.value("lifetime", 1.0)}}}}})}});
};
world.registerBehavior("tutorial.spawn_once", std::move(spawner));
// Ordinary C++ state belongs to the gameplay module. Every handle part matters.
using Key = std::tuple<std::uint64_t, std::uint32_t, std::uint64_t>;
auto ages = std::make_shared<std::map<Key, double>>();
auto key = [](runtime::EntityHandle h) { return Key{h.session, h.slot, h.generation}; };
runtime::Behavior lifetime;
lifetime.onStart = [ages, key](runtime::Runtime&, runtime::EntityHandle self, double) {
(*ages)[key(self)] = 0.0;
};
lifetime.fixedUpdate = [ages, key](runtime::Runtime& game, runtime::EntityHandle self,
double delta) {
auto& age = ages->at(key(self));
age += delta;
if (age >= game.fields(self, "tutorial.timed_despawn").value("seconds", 1.0))
game.destroy(self); // Still valid until the next fixed-tick barrier.
};
lifetime.onDestroy = [ages, key](runtime::Runtime&, runtime::EntityHandle self, double) {
ages->erase(key(self));
};
world.registerBehavior("tutorial.timed_despawn", std::move(lifetime));
}
nlohmann::json schema() {
return nlohmann::json::array(
{{{"id", "tutorial.spawn_once"},
{"version", 1},
{"name", "Spawn once"},
{"fields",
{{"spawned_id",
{{"id", "spawned_id"}, {"type", "string"}, {"default", "temporary-box"}}},
{"lifetime",
{{"id", "lifetime"}, {"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}}},
{{"id", "tutorial.timed_despawn"},
{"version", 1},
{"name", "Timed despawn"},
{"fields",
{{"seconds",
{{"id", "seconds"}, {"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}}}});
}
} // namespace faset::gameplay
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& world);
nlohmann::json schema();
} // namespace faset::gameplay
+58
View File
@@ -0,0 +1,58 @@
{
"format": "faset.scene",
"version": 1,
"id": "tutorial-spawning",
"name": "Spawning tutorial",
"dimension": 2,
"simulation": {
"fixed_delta": 0.016666666666666666,
"max_catch_up_ticks": 4,
"physics_substeps": 4,
"gravity": [
0,
-9.81,
0
]
},
"entities": [
{
"id": "spawner",
"name": "spawner",
"parent": null,
"components": [
{
"id": "spawner-faset.transform",
"type": "faset.transform",
"version": 1,
"fields": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
},
{
"id": "spawner-tutorial.spawn_once",
"type": "tutorial.spawn_once",
"version": 1,
"fields": {
"spawned_id": "temporary-box",
"lifetime": 1.0
}
}
]
}
],
"instances": []
}