Luau Component scripting: a Unity-shaped Script API over the Scene #75

Closed
opened 2026-09-06 01:15:06 +00:00 by crowmaki · 0 comments
Owner

Problem Statement

A game built on Cinnabar has no way to write gameplay without writing an Odin Plugin and recompiling. The glossary already says "Plugins customise the engine; Scripts make the game" and ADR-0015 chose Luau, but nothing links Luau, nothing registers a Component Schema from anything but an Odin type, and nothing calls into a script during a frame. A designer who wants a door that opens when the player approaches has to become an engine developer.

Solution

A luau Plugin that hosts one Luau VM and exposes the Scene to it through an API shaped like Unity's. A Script defines Component classes with typed fields; those become live Component Schemas in Core, their state lives in Core-owned Component storage, and their Awake, Start, Update, FixedUpdate and OnDestroy methods run from the Plugin's Hooks and the Schema's Component Hooks. From a Script, a GameObject is created, found, parented, activated and destroyed, and Components are added, fetched and destroyed, with the same names Unity uses. The Plugin works Headless, so a multiplayer server runs the same Scripts as the client.

User Stories

  1. As a game developer, I want to define a Component in a Luau file with named, typed fields, so that I can add gameplay data to a GameObject without writing Odin.
  2. As a game developer, I want a Component defined in a Script to be a real Component Schema in Core, so that the serializer, the inspector and Odin Plugins see it exactly as they see an Odin Component.
  3. As a game developer, I want a Script Component's state to live in Core storage rather than in the VM, so that a Scene Document saves and loads it and Play Mode capture restores it.
  4. As a game developer, I want to write function Mover:Update(dt) on my Component class and have it called once per frame per live instance, so that behaviour attaches to data the Unity way.
  5. As a game developer, I want FixedUpdate(dt) called once per fixed simulation step, so that movement that feeds physics is deterministic.
  6. As a game developer, I want Awake() to run before the call that added the Component returns, so that a Component can reject itself or seed its fields before anyone reads them.
  7. As a game developer, I want Start() to run once, on the first frame the Component is updated, so that setup that needs other Components on the same GameObject sees them all attached.
  8. As a game developer, I want OnDestroy() to run exactly once whichever way the Component leaves, so that a Script can release what it acquired.
  9. As a game developer, I want self.gameObject inside a method, so that a Component reaches the thing it is attached to.
  10. As a game developer, I want self:GetComponent(Mover) and go:GetComponent(Mover) to return the first Component of that class on the GameObject or nil, so that Components find each other the way they do in Unity.
  11. As a game developer, I want go:GetComponents(Mover) to return every Component of that class on the GameObject, so that the multiple-colliders case is reachable.
  12. As a game developer, I want go:GetComponent("cinnabar.rigidbody") to find an Odin-registered Component by its Schema name, so that a Script can see that a physics body is there and destroy it.
  13. As a game developer, I want go:AddComponent(Mover) to attach a Component and return it, so that Scripts compose GameObjects at runtime.
  14. As a game developer, I want GameObject.new() and GameObject.new(name, parent) to create a GameObject in the top Scene, so that Scripts can spawn things.
  15. As a game developer, I want Destroy(go), go:Destroy(), Destroy(component) and component:Destroy() to queue removal for the next Structural Flush, so that destruction from inside Update is safe.
  16. As a game developer, I want a destroyed GameObject or Component to answer IsAlive() false at once and raise a clear Luau error when I read or write it afterwards, so that a stale reference is a bug I find rather than one that silently does nothing.
  17. As a game developer, I want go.name readable and writable, so that Scripts can label what they spawn.
  18. As a game developer, I want go.parent readable and writable and go:GetChildren() to list children in sibling order, so that Scripts can walk and reshape the hierarchy.
  19. As a game developer, I want go.active and go:SetActive(bool) plus a read-only go.activeInHierarchy, so that Scripts can switch subtrees on and off.
  20. As a game developer, I want Update and FixedUpdate skipped for a Component whose GameObject is not Active in Hierarchy, so that an inactive subtree is truly dormant, as in Unity.
  21. As a game developer, I want go.transform.localPosition and go.transform.position readable and writable as {x, y, z} tables, so that the common case of moving something is one line.
  22. As a game developer, I want go.transform.localMatrix and go.transform.worldMatrix readable and writable as sixteen numbers, so that anything beyond translation is still reachable.
  23. As a game developer, I want GameObject.Find("scene:Room/Lamp") to resolve a Display Path to a GameObject or nil, so that a Script can reach an authored object by the name the editor shows.
  24. As a game developer, I want a field of type GameObject on a Component to be a Reference field, so that a Scene Document stores it as an Object Path and it resolves on load.
  25. As a game developer, I want a reference field whose target Scene is unloaded to read as nil, so that an External Reference behaves the way the glossary says it does.
  26. As a game developer, I want Time.deltaTime, Time.fixedDeltaTime and Time.frameCount, so that a Script can time things without threading arguments through every call.
  27. As a game developer, I want print and warn in a Script to reach the engine logger, so that Script output lands beside engine output.
  28. As a game developer, I want a runtime error in one Component's Update logged with a Luau traceback and the rest of the frame to continue, so that one bug does not stop the game.
  29. As a game developer, I want a Script that fails to compile or fails while defining its Components to be rejected with the old version left running, so that a typo during iteration never takes the engine down.
  30. As a game developer, I want to load a Script by name and source bytes through the Plugin's Interface, so that the Plugin needs no file system and a test can hand it a string.
  31. As a game developer, I want loading a Script under a name that is already loaded to replace it, so that hot reload is one call.
  32. As a game developer, I want a hot-reloaded Component Schema to migrate existing instances by field name, so that adding a field to a running game keeps every other field's value.
  33. As a game developer, I want Awake not to re-run on migrated instances after a hot reload, so that reload changes behaviour without re-initialising state.
  34. As a game developer, I want a Component defined with the same name in two different Scripts to fail the second load with a log message, so that a collision is loud and local.
  35. As a game developer, I want a GameObject or Component created inside Update to receive its first Update on the next frame, so that a frame's iteration is over a stable set.
  36. As a game developer, I want Components of one class to update in storage order, and classes to update in Schema registration order, so that ordering is at least stable across frames.
  37. As a server developer, I want the luau Plugin to require no Backend and no Platform, so that a Headless build runs the same Scripts as the client.
  38. As an engine developer, I want a Core proc that registers a Component Schema from a description, so that the gap ADR-0022 named is closed and the scripting host does not reach into Core's privates.
  39. As an engine developer, I want description-registered Schemas to be legal after startup, so that ADR-0020's live registration is real rather than an intention.
  40. As an engine developer, I want every Core proc the Script API reaches to be a boundary proc under ADR-0022, so that the Scene ABI test keeps proving the seam is C-shaped.
  41. As an engine developer, I want the Luau runtime pinned by version and hash and fetched by one just recipe, so that it arrives the way slangc and Dear ImGui do.
  42. As an engine developer, I want the Luau foreign bindings committed as an Odin package, so that a fresh clone type-checks without a C++ toolchain.
  43. As an engine developer, I want a luau test suite that stands the Plugin up Headless and drives it with Luau source strings, so that every story above is checked by a test in the shape the existing suites already use.
  44. As a tooling developer, I want the Script API to have a stable, documented surface, so that a .d.luau definition file can be written against it later.

Implementation Decisions

Toolchain

  • A fetch-luau recipe downloads the pinned Luau source archive, verifies its hash the way fetch-cimgui does, and builds the Ast, Compiler and VM libraries with LUAU_EXTERN_C and the C++ toolchain the imgui build already needs. Output lands under the tools directory keyed by version.
  • The Odin package wrapping Luau (plugins/luau/luau, sibling to how cimgui sits under imgui) commits hand-written foreign import bindings for the subset of the Luau C API the Plugin uses: state creation and close, compile, load, pcall, stack access, tables, userdata with metatables, light userdata, and lua_setthreaddata or an equivalent for reaching the Plugin State from a C callback. The version constant and the pinned hash both live beside the bindings, the way IMGUI_VERSION does.
  • Every Luau C callback is a proc "c" that establishes context from the Engine pointer it recovers from the VM before calling any Odin proc, per ADR-0008. Errors are raised only as the last statement of a callback, per ADR-0015.
  • just check type-checks the new packages and just test runs the new suite; both are added to the justfile lists.

Core additions

  • A Component_Schema_Desc Boundary Type: a name, an array of field descriptions, and optional on_add and on_remove Hooks. A field description is a name and a Component_Field_Kind. The Kind enum covers only what a description may declare: F64, Bool, GameObject_Reference. Odin-derived Schemas keep their typeid inside Core and report no Kind, so this does not reopen ADR-0022's deferral of a full field-type account.
  • A boundary proc, engine_register_component_schema, registers or replaces a Schema from a description. Core maps each Kind to the Odin type it already serializes, computes layout, and opens storage. It becomes the boundary route the Scene ABI test currently records as missing for engine_register_component.
  • Registration after startup is legal for description-registered Schemas. Replacing a Schema under live instances migrates them through the serializer by field name, per ADR-0020: kept fields carry over, removed fields drop, added fields zero. Object Ids and Component Handles survive. Replacing an Odin-registered Schema stays illegal. A name collision after startup fails the call and logs; the running Scene is untouched.
  • Every Component Schema, description-registered or not, may carry one Core-owned Transient bit that the scripting host uses to remember whether Start has run. It is expressed as a Transient Field the host declares in the description, not as a Core concept.

The luau Plugin

  • Publishes cinnabar.script/1. The Interface has load(self, name, source: Byte_Span) -> bool and unload(self, name) -> bool. It requires no other Interface and works Headless.
  • One VM per Plugin State, created in register, closed in shutdown. A Script is one chunk compiled and run in a fresh environment table so two Scripts do not share globals. The chunk's job is to define Component classes; it runs once per load.
  • Component.define(name, fields) creates a class table and registers a Schema through the Core proc above. fields maps field name to a default value whose Luau type picks the Kind: a number is F64, a boolean is Bool, and the GameObject class marks a Reference. Defaults are applied in the on_add trampoline before Awake runs. Any other value type fails the define with an error naming the field.
  • A Component instance reaching Luau is a userdata carrying the Component Handle, with a shared metatable per class. Reading self.speed resolves the field index once per class and reads Core storage through component_data and the Schema field offset; writing goes the same way. No Component state is held in the VM. The userdata is cached per Handle in a weak table so identity comparison works within a frame.
  • A GameObject reaching Luau is a userdata carrying the GameObject Handle, cached the same way. Its metatable exposes name, parent, active, activeInHierarchy, transform, IsAlive, SetActive, GetChildren, AddComponent, GetComponent, GetComponents, Destroy. GameObject.new and GameObject.Find are statics.
  • transform is a small userdata bound to the same Handle. position and localPosition are the translation column of the World Transform and the local transform; writing them replaces only that column and goes through gameobject_set_world_transform or gameobject_set_local_transform. worldMatrix and localMatrix are the full sixteen numbers.
  • GetComponent accepts a class table or a Schema name string. A Component that is not Script-defined comes back as a generic userdata with gameObject, IsAlive, Destroy and no field access.
  • Any access to a dead Handle raises a Luau error that names the operation. IsAlive is the one exception and never raises.
  • Lifecycle mapping: Awake runs from the Schema's on_add Hook; OnDestroy from on_remove; Update(dt) from the Plugin's update Hook; FixedUpdate(dt) from fixed_update. Start() runs immediately before the first Update of an instance whose Transient started bit is clear, then sets the bit. Instances on a GameObject that is not Active in Hierarchy are skipped by Update, FixedUpdate and therefore Start.
  • The Plugin's per-frame Hooks walk each Script class in Schema registration order and, within a class, walk component_storage by index, re-fetching storage every iteration because a call into a Script may add Components and move it. A Component added during the walk is beyond the count captured at the start of that class's walk and is first updated next frame.
  • Every entry into the VM goes through lua_pcall. A failing call logs the message and traceback under the Plugin's logger and returns; the frame continues.
  • load compiles and runs the chunk; on any failure the previously loaded version of that Script, and every Schema it defined, stays in force. On success the Script's classes replace their predecessors: Schemas are re-registered under the same names, instances migrate, and the class tables' methods are swapped. Awake does not re-run. A Script that stops defining a class it used to define leaves that Schema registered; storage for it is not reclaimed in this spec.
  • Time is a table the Plugin refreshes at the top of each Hook from Frame Timing. print and warn are rebound to the logger at info and warning levels.

Not touched

  • The Plugin does not read files. The sample or the game reads the file and hands bytes across; an Asset system arrives later and slots in above this Interface.
  • Core's frame loop, the Frame Step list, and the Structural Flush are unchanged. Scripts observe Wants Destroy and the flush exactly as Odin Components do.

Testing Decisions

A good test builds an Engine from a Plugin list, hands the luau Plugin source text through its Interface, runs frames with engine_tick_with_delta, and asserts on what Core reports: component_count, gameobject_is_alive, component_is_alive, component_data read through the Schema, gameobject_world_transform, gameobject_name, scene_object_count. A test may also let a Script write into a Component field and read the field back from Odin, which is the cleanest way to observe what a Script did. Tests do not inspect the VM, the binding layer, or the Plugin State.

The suite is tests/luau, listed beside imgui in the justfile because both need a fetched native library. Prior art:

  • tests/core/component_lifecycle_test.odin for Hook ordering, flush timing, and Wants Destroy from inside on_add.
  • tests/core/imgui_test.odin for standing a Plugin up over a foreign library Headless.
  • tests/core/scene_abi_test.odin, which must be extended so the new Core proc and Boundary Types are placed in its tables and the "no boundary route" entry for Odin-type registration is retired.
  • tests/core/scene_document_test.odin and play_mode_test.odin for round-tripping a description-registered Schema through both Scene Encodings and for migration by field name.

Behaviours each of which gets at least one test: define and add a Component from Luau; field default, read and write visible from Odin; Awake before the adding call returns; Start once and only for active-in-hierarchy instances; Update and FixedUpdate counts across a multi-step Tick; OnDestroy exactly once for direct destroy, owner destroy, and Scene unload; GetComponent and GetComponents including by Schema name; GameObject.new, parent, children, name, active; transform position round trip; Destroy from inside Update completes over the pre-flush set; a dead Handle raises; a compile error keeps the previous Script; hot reload migrates by field name and does not re-run Awake; Reference field saves as an Object Path and reads as nil when unresolved; Script errors do not stop the frame; the whole suite runs with no Backend and no Platform.

Core-level tests for engine_register_component_schema live in the core suite and need no Luau: registration from a description, post-startup registration, replacement with migration, collision after startup leaving the Scene untouched.

Out of Scope

  • Reading Script files from disk, an Asset system, or Asset Ids for Scripts.
  • The .d.luau definition generator ADR-0015 mentions; the API surface here is what it will describe.
  • Edit Mode versus Play Mode gating of Script execution: no editor exists yet, so Scripts run whenever the Plugin is loaded.
  • Prefabs and Instantiate. GameObject.new is the only spawn.
  • Rotation and scale helpers on transform; the matrix is the escape hatch.
  • Field kinds beyond number, boolean and GameObject reference: strings, vectors, arrays, nested tables, Asset references.
  • Field access from Luau on Odin-registered Components.
  • Reclaiming storage for a Schema a reloaded Script stopped defining.
  • Script-to-Script messaging, coroutines, or Event Channel access from Luau.
  • A debugger protocol; tracebacks only, per ADR-0015.
  • Multiple VMs or sandboxing between Scripts beyond separate environment tables.

Further Notes

  • ADR-0015's research document is referenced but not present in the repo; nothing here depends on it.
  • ADR-0018 says a Script "reaches fields through the Schema by index"; the field-index cache per class is that rule made concrete.
  • ADR-0022 deferred a field-kind enum because it would have to cover every Odin shape. The Kind introduced here is scoped to descriptions only and is not reported for Odin fields, which respects that deferral. A future full account can subsume it.
  • The Plugin List Order rule applies: a game that wants Scripts to see this frame's physics results lists luau after the physics Plugin.
  • The scene ABI test enforces the boundary by reading source files, so adding the new proc will fail that test until it is placed; that failure is the intended prompt.
  • A follow-up worth filing once this lands: the headless sample loading a Script from a file, so the feature is visible outside the test suite.
## Problem Statement A game built on Cinnabar has no way to write gameplay without writing an Odin Plugin and recompiling. The glossary already says "Plugins customise the engine; Scripts make the game" and ADR-0015 chose Luau, but nothing links Luau, nothing registers a Component Schema from anything but an Odin type, and nothing calls into a script during a frame. A designer who wants a door that opens when the player approaches has to become an engine developer. ## Solution A `luau` Plugin that hosts one Luau VM and exposes the Scene to it through an API shaped like Unity's. A Script defines Component classes with typed fields; those become live Component Schemas in Core, their state lives in Core-owned Component storage, and their `Awake`, `Start`, `Update`, `FixedUpdate` and `OnDestroy` methods run from the Plugin's Hooks and the Schema's Component Hooks. From a Script, a GameObject is created, found, parented, activated and destroyed, and Components are added, fetched and destroyed, with the same names Unity uses. The Plugin works Headless, so a multiplayer server runs the same Scripts as the client. ## User Stories 1. As a game developer, I want to define a Component in a Luau file with named, typed fields, so that I can add gameplay data to a GameObject without writing Odin. 2. As a game developer, I want a Component defined in a Script to be a real Component Schema in Core, so that the serializer, the inspector and Odin Plugins see it exactly as they see an Odin Component. 3. As a game developer, I want a Script Component's state to live in Core storage rather than in the VM, so that a Scene Document saves and loads it and Play Mode capture restores it. 4. As a game developer, I want to write `function Mover:Update(dt)` on my Component class and have it called once per frame per live instance, so that behaviour attaches to data the Unity way. 5. As a game developer, I want `FixedUpdate(dt)` called once per fixed simulation step, so that movement that feeds physics is deterministic. 6. As a game developer, I want `Awake()` to run before the call that added the Component returns, so that a Component can reject itself or seed its fields before anyone reads them. 7. As a game developer, I want `Start()` to run once, on the first frame the Component is updated, so that setup that needs other Components on the same GameObject sees them all attached. 8. As a game developer, I want `OnDestroy()` to run exactly once whichever way the Component leaves, so that a Script can release what it acquired. 9. As a game developer, I want `self.gameObject` inside a method, so that a Component reaches the thing it is attached to. 10. As a game developer, I want `self:GetComponent(Mover)` and `go:GetComponent(Mover)` to return the first Component of that class on the GameObject or `nil`, so that Components find each other the way they do in Unity. 11. As a game developer, I want `go:GetComponents(Mover)` to return every Component of that class on the GameObject, so that the multiple-colliders case is reachable. 12. As a game developer, I want `go:GetComponent("cinnabar.rigidbody")` to find an Odin-registered Component by its Schema name, so that a Script can see that a physics body is there and destroy it. 13. As a game developer, I want `go:AddComponent(Mover)` to attach a Component and return it, so that Scripts compose GameObjects at runtime. 14. As a game developer, I want `GameObject.new()` and `GameObject.new(name, parent)` to create a GameObject in the top Scene, so that Scripts can spawn things. 15. As a game developer, I want `Destroy(go)`, `go:Destroy()`, `Destroy(component)` and `component:Destroy()` to queue removal for the next Structural Flush, so that destruction from inside `Update` is safe. 16. As a game developer, I want a destroyed GameObject or Component to answer `IsAlive()` false at once and raise a clear Luau error when I read or write it afterwards, so that a stale reference is a bug I find rather than one that silently does nothing. 17. As a game developer, I want `go.name` readable and writable, so that Scripts can label what they spawn. 18. As a game developer, I want `go.parent` readable and writable and `go:GetChildren()` to list children in sibling order, so that Scripts can walk and reshape the hierarchy. 19. As a game developer, I want `go.active` and `go:SetActive(bool)` plus a read-only `go.activeInHierarchy`, so that Scripts can switch subtrees on and off. 20. As a game developer, I want `Update` and `FixedUpdate` skipped for a Component whose GameObject is not Active in Hierarchy, so that an inactive subtree is truly dormant, as in Unity. 21. As a game developer, I want `go.transform.localPosition` and `go.transform.position` readable and writable as `{x, y, z}` tables, so that the common case of moving something is one line. 22. As a game developer, I want `go.transform.localMatrix` and `go.transform.worldMatrix` readable and writable as sixteen numbers, so that anything beyond translation is still reachable. 23. As a game developer, I want `GameObject.Find("scene:Room/Lamp")` to resolve a Display Path to a GameObject or `nil`, so that a Script can reach an authored object by the name the editor shows. 24. As a game developer, I want a field of type `GameObject` on a Component to be a Reference field, so that a Scene Document stores it as an Object Path and it resolves on load. 25. As a game developer, I want a reference field whose target Scene is unloaded to read as `nil`, so that an External Reference behaves the way the glossary says it does. 26. As a game developer, I want `Time.deltaTime`, `Time.fixedDeltaTime` and `Time.frameCount`, so that a Script can time things without threading arguments through every call. 27. As a game developer, I want `print` and `warn` in a Script to reach the engine logger, so that Script output lands beside engine output. 28. As a game developer, I want a runtime error in one Component's `Update` logged with a Luau traceback and the rest of the frame to continue, so that one bug does not stop the game. 29. As a game developer, I want a Script that fails to compile or fails while defining its Components to be rejected with the old version left running, so that a typo during iteration never takes the engine down. 30. As a game developer, I want to load a Script by name and source bytes through the Plugin's Interface, so that the Plugin needs no file system and a test can hand it a string. 31. As a game developer, I want loading a Script under a name that is already loaded to replace it, so that hot reload is one call. 32. As a game developer, I want a hot-reloaded Component Schema to migrate existing instances by field name, so that adding a field to a running game keeps every other field's value. 33. As a game developer, I want `Awake` not to re-run on migrated instances after a hot reload, so that reload changes behaviour without re-initialising state. 34. As a game developer, I want a Component defined with the same name in two different Scripts to fail the second load with a log message, so that a collision is loud and local. 35. As a game developer, I want a GameObject or Component created inside `Update` to receive its first `Update` on the next frame, so that a frame's iteration is over a stable set. 36. As a game developer, I want Components of one class to update in storage order, and classes to update in Schema registration order, so that ordering is at least stable across frames. 37. As a server developer, I want the `luau` Plugin to require no Backend and no Platform, so that a Headless build runs the same Scripts as the client. 38. As an engine developer, I want a Core proc that registers a Component Schema from a description, so that the gap ADR-0022 named is closed and the scripting host does not reach into Core's privates. 39. As an engine developer, I want description-registered Schemas to be legal after startup, so that ADR-0020's live registration is real rather than an intention. 40. As an engine developer, I want every Core proc the Script API reaches to be a boundary proc under ADR-0022, so that the Scene ABI test keeps proving the seam is C-shaped. 41. As an engine developer, I want the Luau runtime pinned by version and hash and fetched by one `just` recipe, so that it arrives the way `slangc` and Dear ImGui do. 42. As an engine developer, I want the Luau foreign bindings committed as an Odin package, so that a fresh clone type-checks without a C++ toolchain. 43. As an engine developer, I want a `luau` test suite that stands the Plugin up Headless and drives it with Luau source strings, so that every story above is checked by a test in the shape the existing suites already use. 44. As a tooling developer, I want the Script API to have a stable, documented surface, so that a `.d.luau` definition file can be written against it later. ## Implementation Decisions ### Toolchain - A `fetch-luau` recipe downloads the pinned Luau source archive, verifies its hash the way `fetch-cimgui` does, and builds the Ast, Compiler and VM libraries with `LUAU_EXTERN_C` and the C++ toolchain the imgui build already needs. Output lands under the tools directory keyed by version. - The Odin package wrapping Luau (`plugins/luau/luau`, sibling to how cimgui sits under imgui) commits hand-written `foreign import` bindings for the subset of the Luau C API the Plugin uses: state creation and close, compile, load, `pcall`, stack access, tables, userdata with metatables, light userdata, and `lua_setthreaddata` or an equivalent for reaching the Plugin State from a C callback. The version constant and the pinned hash both live beside the bindings, the way `IMGUI_VERSION` does. - Every Luau C callback is a `proc "c"` that establishes `context` from the Engine pointer it recovers from the VM before calling any Odin proc, per ADR-0008. Errors are raised only as the last statement of a callback, per ADR-0015. - `just check` type-checks the new packages and `just test` runs the new suite; both are added to the justfile lists. ### Core additions - A `Component_Schema_Desc` Boundary Type: a name, an array of field descriptions, and optional `on_add` and `on_remove` Hooks. A field description is a name and a `Component_Field_Kind`. The Kind enum covers only what a description may declare: `F64`, `Bool`, `GameObject_Reference`. Odin-derived Schemas keep their `typeid` inside Core and report no Kind, so this does not reopen ADR-0022's deferral of a full field-type account. - A boundary proc, `engine_register_component_schema`, registers or replaces a Schema from a description. Core maps each Kind to the Odin type it already serializes, computes layout, and opens storage. It becomes the boundary route the Scene ABI test currently records as missing for `engine_register_component`. - Registration after startup is legal for description-registered Schemas. Replacing a Schema under live instances migrates them through the serializer by field name, per ADR-0020: kept fields carry over, removed fields drop, added fields zero. Object Ids and Component Handles survive. Replacing an Odin-registered Schema stays illegal. A name collision after startup fails the call and logs; the running Scene is untouched. - Every Component Schema, description-registered or not, may carry one Core-owned Transient bit that the scripting host uses to remember whether `Start` has run. It is expressed as a Transient Field the host declares in the description, not as a Core concept. ### The `luau` Plugin - Publishes `cinnabar.script/1`. The Interface has `load(self, name, source: Byte_Span) -> bool` and `unload(self, name) -> bool`. It requires no other Interface and works Headless. - One VM per Plugin State, created in `register`, closed in `shutdown`. A Script is one chunk compiled and run in a fresh environment table so two Scripts do not share globals. The chunk's job is to define Component classes; it runs once per load. - `Component.define(name, fields)` creates a class table and registers a Schema through the Core proc above. `fields` maps field name to a default value whose Luau type picks the Kind: a number is `F64`, a boolean is `Bool`, and the `GameObject` class marks a Reference. Defaults are applied in the `on_add` trampoline before `Awake` runs. Any other value type fails the define with an error naming the field. - A Component instance reaching Luau is a userdata carrying the Component Handle, with a shared metatable per class. Reading `self.speed` resolves the field index once per class and reads Core storage through `component_data` and the Schema field offset; writing goes the same way. No Component state is held in the VM. The userdata is cached per Handle in a weak table so identity comparison works within a frame. - A GameObject reaching Luau is a userdata carrying the GameObject Handle, cached the same way. Its metatable exposes `name`, `parent`, `active`, `activeInHierarchy`, `transform`, `IsAlive`, `SetActive`, `GetChildren`, `AddComponent`, `GetComponent`, `GetComponents`, `Destroy`. `GameObject.new` and `GameObject.Find` are statics. - `transform` is a small userdata bound to the same Handle. `position` and `localPosition` are the translation column of the World Transform and the local transform; writing them replaces only that column and goes through `gameobject_set_world_transform` or `gameobject_set_local_transform`. `worldMatrix` and `localMatrix` are the full sixteen numbers. - `GetComponent` accepts a class table or a Schema name string. A Component that is not Script-defined comes back as a generic userdata with `gameObject`, `IsAlive`, `Destroy` and no field access. - Any access to a dead Handle raises a Luau error that names the operation. `IsAlive` is the one exception and never raises. - Lifecycle mapping: `Awake` runs from the Schema's `on_add` Hook; `OnDestroy` from `on_remove`; `Update(dt)` from the Plugin's `update` Hook; `FixedUpdate(dt)` from `fixed_update`. `Start()` runs immediately before the first `Update` of an instance whose Transient started bit is clear, then sets the bit. Instances on a GameObject that is not Active in Hierarchy are skipped by `Update`, `FixedUpdate` and therefore `Start`. - The Plugin's per-frame Hooks walk each Script class in Schema registration order and, within a class, walk `component_storage` by index, re-fetching storage every iteration because a call into a Script may add Components and move it. A Component added during the walk is beyond the count captured at the start of that class's walk and is first updated next frame. - Every entry into the VM goes through `lua_pcall`. A failing call logs the message and traceback under the Plugin's logger and returns; the frame continues. - `load` compiles and runs the chunk; on any failure the previously loaded version of that Script, and every Schema it defined, stays in force. On success the Script's classes replace their predecessors: Schemas are re-registered under the same names, instances migrate, and the class tables' methods are swapped. `Awake` does not re-run. A Script that stops defining a class it used to define leaves that Schema registered; storage for it is not reclaimed in this spec. - `Time` is a table the Plugin refreshes at the top of each Hook from Frame Timing. `print` and `warn` are rebound to the logger at info and warning levels. ### Not touched - The Plugin does not read files. The sample or the game reads the file and hands bytes across; an Asset system arrives later and slots in above this Interface. - Core's frame loop, the Frame Step list, and the Structural Flush are unchanged. Scripts observe Wants Destroy and the flush exactly as Odin Components do. ## Testing Decisions A good test builds an Engine from a Plugin list, hands the `luau` Plugin source text through its Interface, runs frames with `engine_tick_with_delta`, and asserts on what Core reports: `component_count`, `gameobject_is_alive`, `component_is_alive`, `component_data` read through the Schema, `gameobject_world_transform`, `gameobject_name`, `scene_object_count`. A test may also let a Script write into a Component field and read the field back from Odin, which is the cleanest way to observe what a Script did. Tests do not inspect the VM, the binding layer, or the Plugin State. The suite is `tests/luau`, listed beside `imgui` in the justfile because both need a fetched native library. Prior art: - `tests/core/component_lifecycle_test.odin` for Hook ordering, flush timing, and Wants Destroy from inside `on_add`. - `tests/core/imgui_test.odin` for standing a Plugin up over a foreign library Headless. - `tests/core/scene_abi_test.odin`, which must be extended so the new Core proc and Boundary Types are placed in its tables and the "no boundary route" entry for Odin-type registration is retired. - `tests/core/scene_document_test.odin` and `play_mode_test.odin` for round-tripping a description-registered Schema through both Scene Encodings and for migration by field name. Behaviours each of which gets at least one test: define and add a Component from Luau; field default, read and write visible from Odin; Awake before the adding call returns; Start once and only for active-in-hierarchy instances; Update and FixedUpdate counts across a multi-step Tick; OnDestroy exactly once for direct destroy, owner destroy, and Scene unload; GetComponent and GetComponents including by Schema name; GameObject.new, parent, children, name, active; transform position round trip; Destroy from inside Update completes over the pre-flush set; a dead Handle raises; a compile error keeps the previous Script; hot reload migrates by field name and does not re-run Awake; Reference field saves as an Object Path and reads as nil when unresolved; Script errors do not stop the frame; the whole suite runs with no Backend and no Platform. Core-level tests for `engine_register_component_schema` live in the core suite and need no Luau: registration from a description, post-startup registration, replacement with migration, collision after startup leaving the Scene untouched. ## Out of Scope - Reading Script files from disk, an Asset system, or Asset Ids for Scripts. - The `.d.luau` definition generator ADR-0015 mentions; the API surface here is what it will describe. - Edit Mode versus Play Mode gating of Script execution: no editor exists yet, so Scripts run whenever the Plugin is loaded. - Prefabs and `Instantiate`. `GameObject.new` is the only spawn. - Rotation and scale helpers on `transform`; the matrix is the escape hatch. - Field kinds beyond number, boolean and GameObject reference: strings, vectors, arrays, nested tables, Asset references. - Field access from Luau on Odin-registered Components. - Reclaiming storage for a Schema a reloaded Script stopped defining. - Script-to-Script messaging, coroutines, or Event Channel access from Luau. - A debugger protocol; tracebacks only, per ADR-0015. - Multiple VMs or sandboxing between Scripts beyond separate environment tables. ## Further Notes - ADR-0015's research document is referenced but not present in the repo; nothing here depends on it. - ADR-0018 says a Script "reaches fields through the Schema by index"; the field-index cache per class is that rule made concrete. - ADR-0022 deferred a field-kind enum because it would have to cover every Odin shape. The Kind introduced here is scoped to descriptions only and is not reported for Odin fields, which respects that deferral. A future full account can subsume it. - The Plugin List Order rule applies: a game that wants Scripts to see this frame's physics results lists `luau` after the physics Plugin. - The scene ABI test enforces the boundary by reading source files, so adding the new proc will fail that test until it is placed; that failure is the intended prompt. - A follow-up worth filing once this lands: the headless sample loading a Script from a file, so the feature is visible outside the test suite.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crowmaki/cinnabar#75
No description provided.