Luau Component scripting: a Unity-shaped Script API over the Scene #75
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crowmaki/cinnabar#75
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
luauPlugin 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 theirAwake,Start,Update,FixedUpdateandOnDestroymethods 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
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.FixedUpdate(dt)called once per fixed simulation step, so that movement that feeds physics is deterministic.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.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.OnDestroy()to run exactly once whichever way the Component leaves, so that a Script can release what it acquired.self.gameObjectinside a method, so that a Component reaches the thing it is attached to.self:GetComponent(Mover)andgo:GetComponent(Mover)to return the first Component of that class on the GameObject ornil, so that Components find each other the way they do in Unity.go:GetComponents(Mover)to return every Component of that class on the GameObject, so that the multiple-colliders case is reachable.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.go:AddComponent(Mover)to attach a Component and return it, so that Scripts compose GameObjects at runtime.GameObject.new()andGameObject.new(name, parent)to create a GameObject in the top Scene, so that Scripts can spawn things.Destroy(go),go:Destroy(),Destroy(component)andcomponent:Destroy()to queue removal for the next Structural Flush, so that destruction from insideUpdateis safe.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.go.namereadable and writable, so that Scripts can label what they spawn.go.parentreadable and writable andgo:GetChildren()to list children in sibling order, so that Scripts can walk and reshape the hierarchy.go.activeandgo:SetActive(bool)plus a read-onlygo.activeInHierarchy, so that Scripts can switch subtrees on and off.UpdateandFixedUpdateskipped for a Component whose GameObject is not Active in Hierarchy, so that an inactive subtree is truly dormant, as in Unity.go.transform.localPositionandgo.transform.positionreadable and writable as{x, y, z}tables, so that the common case of moving something is one line.go.transform.localMatrixandgo.transform.worldMatrixreadable and writable as sixteen numbers, so that anything beyond translation is still reachable.GameObject.Find("scene:Room/Lamp")to resolve a Display Path to a GameObject ornil, so that a Script can reach an authored object by the name the editor shows.GameObjecton a Component to be a Reference field, so that a Scene Document stores it as an Object Path and it resolves on load.nil, so that an External Reference behaves the way the glossary says it does.Time.deltaTime,Time.fixedDeltaTimeandTime.frameCount, so that a Script can time things without threading arguments through every call.printandwarnin a Script to reach the engine logger, so that Script output lands beside engine output.Updatelogged with a Luau traceback and the rest of the frame to continue, so that one bug does not stop the game.Awakenot to re-run on migrated instances after a hot reload, so that reload changes behaviour without re-initialising state.Updateto receive its firstUpdateon the next frame, so that a frame's iteration is over a stable set.luauPlugin to require no Backend and no Platform, so that a Headless build runs the same Scripts as the client.justrecipe, so that it arrives the wayslangcand Dear ImGui do.luautest 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..d.luaudefinition file can be written against it later.Implementation Decisions
Toolchain
fetch-luaurecipe downloads the pinned Luau source archive, verifies its hash the wayfetch-cimguidoes, and builds the Ast, Compiler and VM libraries withLUAU_EXTERN_Cand the C++ toolchain the imgui build already needs. Output lands under the tools directory keyed by version.plugins/luau/luau, sibling to how cimgui sits under imgui) commits hand-writtenforeign importbindings 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, andlua_setthreaddataor an equivalent for reaching the Plugin State from a C callback. The version constant and the pinned hash both live beside the bindings, the wayIMGUI_VERSIONdoes.proc "c"that establishescontextfrom 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 checktype-checks the new packages andjust testruns the new suite; both are added to the justfile lists.Core additions
Component_Schema_DescBoundary Type: a name, an array of field descriptions, and optionalon_addandon_removeHooks. A field description is a name and aComponent_Field_Kind. The Kind enum covers only what a description may declare:F64,Bool,GameObject_Reference. Odin-derived Schemas keep theirtypeidinside Core and report no Kind, so this does not reopen ADR-0022's deferral of a full field-type account.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 forengine_register_component.Starthas run. It is expressed as a Transient Field the host declares in the description, not as a Core concept.The
luauPlugincinnabar.script/1. The Interface hasload(self, name, source: Byte_Span) -> boolandunload(self, name) -> bool. It requires no other Interface and works Headless.register, closed inshutdown. 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.fieldsmaps field name to a default value whose Luau type picks the Kind: a number isF64, a boolean isBool, and theGameObjectclass marks a Reference. Defaults are applied in theon_addtrampoline beforeAwakeruns. Any other value type fails the define with an error naming the field.self.speedresolves the field index once per class and reads Core storage throughcomponent_dataand 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.name,parent,active,activeInHierarchy,transform,IsAlive,SetActive,GetChildren,AddComponent,GetComponent,GetComponents,Destroy.GameObject.newandGameObject.Findare statics.transformis a small userdata bound to the same Handle.positionandlocalPositionare the translation column of the World Transform and the local transform; writing them replaces only that column and goes throughgameobject_set_world_transformorgameobject_set_local_transform.worldMatrixandlocalMatrixare the full sixteen numbers.GetComponentaccepts a class table or a Schema name string. A Component that is not Script-defined comes back as a generic userdata withgameObject,IsAlive,Destroyand no field access.IsAliveis the one exception and never raises.Awakeruns from the Schema'son_addHook;OnDestroyfromon_remove;Update(dt)from the Plugin'supdateHook;FixedUpdate(dt)fromfixed_update.Start()runs immediately before the firstUpdateof an instance whose Transient started bit is clear, then sets the bit. Instances on a GameObject that is not Active in Hierarchy are skipped byUpdate,FixedUpdateand thereforeStart.component_storageby 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.lua_pcall. A failing call logs the message and traceback under the Plugin's logger and returns; the frame continues.loadcompiles 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.Awakedoes 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.Timeis a table the Plugin refreshes at the top of each Hook from Frame Timing.printandwarnare rebound to the logger at info and warning levels.Not touched
Testing Decisions
A good test builds an Engine from a Plugin list, hands the
luauPlugin source text through its Interface, runs frames withengine_tick_with_delta, and asserts on what Core reports:component_count,gameobject_is_alive,component_is_alive,component_dataread 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 besideimguiin the justfile because both need a fetched native library. Prior art:tests/core/component_lifecycle_test.odinfor Hook ordering, flush timing, and Wants Destroy from insideon_add.tests/core/imgui_test.odinfor 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.odinandplay_mode_test.odinfor 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_schemalive 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
.d.luaudefinition generator ADR-0015 mentions; the API surface here is what it will describe.Instantiate.GameObject.newis the only spawn.transform; the matrix is the escape hatch.Further Notes
luauafter the physics Plugin.