RadicalBeard.Evaluate
0.12.0
dotnet add package RadicalBeard.Evaluate --version 0.12.0
NuGet\Install-Package RadicalBeard.Evaluate -Version 0.12.0
<PackageReference Include="RadicalBeard.Evaluate" Version="0.12.0" />
<PackageVersion Include="RadicalBeard.Evaluate" Version="0.12.0" />
<PackageReference Include="RadicalBeard.Evaluate" />
paket add RadicalBeard.Evaluate --version 0.12.0
#r "nuget: RadicalBeard.Evaluate, 0.12.0"
#:package RadicalBeard.Evaluate@0.12.0
#addin nuget:?package=RadicalBeard.Evaluate&version=0.12.0
#tool nuget:?package=RadicalBeard.Evaluate&version=0.12.0
<p align="center"><img src="logo.png" alt="Evaluate" width="180"></p>
Evaluate
A data-driven, moddable game framework built as an extension that runs inside
Godot (Godot is the host; Evaluate has no main). Engine-side code is .NET/C#
(net10.0, Godot 4.6-mono). Gameplay is authored in "evt" scripts whose YAML
frontmatter declares a capability-scoped signature; a custom loader sandboxes
each script to exactly what it declares. Scripts are auto-discovered and
hot-reloaded by default.
Architecture
- Frontmatter → signature. The leading
---block is split off and parsed as real YAML (YamlDotNet) intoconfig:,apis:,register:,returns:,params:,require:,assets:,scenes:,properties:,behaviors:,machines:,attributes:,abilities:(plus the machine-onlyname:/states:/initial:); the Lua body is handed to the VM verbatim (line numbers preserved). The signature is the C#↔Lua boundary contract —returnsdeclares typed, access-scoped members (no Lua type system). (src/Evaluate/Frontmatter.cs) - Per-script sandbox — apis as modules. Each body runs via
load(body, name, "t", env)on a sharedLuaState, whereenvholds only the declaredconfig.*/assets.*, each declaredapis:entry as its own bare global table, the always-availablestd, plus a few safe primitives — including the metatable builtins (setmetatable/getmetatable/rawget/rawset/rawequal/rawlen) so scripts can define classes the idiomatic Lua way. There is no ambientgodot.*: anapis:name resolves framework service → host-registered extension → Godot class/enum (memoized), soapis: [save, Node3D, Timer, Side]injects thesaveservice next toNode3D.new(),Timer.TimerProcessCallback.IdleandSide.Bottom. Declaration gates the class table only — instances reaching a script (self,get_node, signal args, return values) expose members regardless. An unknownapis:name is a load error (typo-proofing); a legacygodot:-prefixed entry errors with a migration hint;ResourceLoader/ResourceSaver/FileAccess/DirAccessare blocked from declaration (assets come fromassets:, persistence fromsave/sql), and so is the whole raw-input surface —Input,InputMap,Key,JoyButton,JoyAxis,MouseButton, everyInputEvent*— because input is native: subscribe to mapped actions via theactionsapi instead. Undeclared globals (os,io, …) are absent, andpcallis intentionally withheld (errors surface rather than being swallowed). (src/Evaluate/Loader.cs) - Host extension apis. The embedding game registers C# api modules with
runtime.RegisterApi("combat_native", implObjectOrStaticType)before adding the runtime to the tree; scripts declareapis: [combat_native]and call the public methods dot-style. Reserved names, collisions with Godot classes, duplicate names, and post-load registrations are all rejected. - Assets are declared, not loaded. Frontmatter
assets:is a map (name: "res-relative/path"; a filename*glob binds a stem-keyed table), eagerly loaded at script load (missing file = load error) and injected as the ambientassetstable — the only way a script gets an asset. Hot reload is real: a.gdshaderedit updates in place (the sameShaderinstance carries the new code, so liveShaderMaterials recompile for free — shaders live in their own.gdshaderfiles, never inline), while any other resource re-loads with cache-replace and its declaring scripts re-runon_unload/on_load. - Custom
requirenarrows the returned module to itsreturnscontract (get/set property →get_/set_accessors; plain method; read-only hides the setter; missing accessor errors). A script may also declare its modules in frontmatter —require:binds each to a sandbox local (require: { base: "lib/base.evt" }→baseusable with nolocal base = require(...)line), resolving the same narrowed handle. A require cycle (direct or transitive, including self-require) is rejected with the offending chain rather than a stack overflow, and editing a required module hot-reloads every consumer that requires it (transitively — systems re-run, live behaviors refresh). - Data lives in the engine. Game objects are Godot nodes — declared in scene
files or created via a declared class module (
Node3D.new()) /world— so there is no separate entity system. The Lua handle is a thin proxy whose metatable routes reads/writes into the engine. (src/Evaluate/GodotBinder.cs) - Auto-discovery. The runtime recursively scans
res://scripts; any system.evtwith aregister:block is wired to the Godot lifecycle. A hook may be registered once per scene (see Scenes & layers) — so the sameon_updatecan be registered inmenu,level1, and globally, each by a different script. - Hot reload (default). A
FileSystemWatcherwatches scripts, scene files, configs, frontmatter-declaredassets:, and.ability/.effectfiles; changes reload on the main thread. A changed system or behavior script re-runs its body and refreshes its hook closures while live nodes persist; a changed*.scenerebuilds the active scene. (src/Evaluate/EvaluateRuntime.cs) - Scenes & layers. Gameplay is split into a persistent global layer and a
swappable scene layer, so one program holds many scenes, each with its own
registered functions:
global.scene(reserved manifest) declares nodes that never clear plusstart_sceneand (optionally)controls = "…toml"— the action map the implicit native PlayerController loads (see Native input below). Loaded once into a persistent Global root.*.scenefiles (TOML content) declare a node tree as keyed, nested tables —[nodes.Player]then[nodes.Player.Camera]makes Camera a child of Player. A node's name is the table key; reserved keystype/behaviors/machines(/script, the deprecated alias); a sub-table is a child node and a scalar/array is a property (position = [x, y, z]). A sub-table tagged with_typeis a property, not a child — it names an inline resource (mesh = { _type = "BoxMesh", size = [1,1,1] }) or a builtin struct (custom_aabb = { _type = "Aabb", position = [1,2,3], size = [4,5,6] }), which is how composite structs likeTransform3D/Rect2/Basis/Projectionare written. A property key may be quoted to carry a/(Control theme overrides:"theme_override_colors/font_color" = [..]). An optional top-leveldescription = "…"holds scene docs as data. Parsed with Tomlyn; instantiated under a per-scene container that is freed wholesale on switch. The editor addon can round-trip all of this — includingbehaviors/machineslists — back to the.scene(see below).*.behavior.evtis node behavior, attached via the scene'sbehaviors = ["path", { script = "path", params = {…} }]list; its hooks run withselfbound to that node (no spawning in the script). A node holds N behaviors (hooks fire in attachment order) and one behavior file drives many nodes, each attachment with its own params. A behavior's own frontmatterbehaviors:/machines:lists compose further attachments onto the same node (depth-first, deduped per node).*.node.evt+script =remain as the single-behavior alias (deprecated; new code uses behaviors). A behavior may declare aparams:block — typed, per-instance values the scene supplies and the body reads through the ambientparamstable; the per-node analogue ofconfig(which is shared). Entries arename: <default>(type inferred),name: <type>(required), orname: "<type> = <default>"; types includedna— a hand-authored 64-bit identity hash ("0x"+ exactly 16 hex digits, never framework-generated) the body reads asparams.<name>:trait(1..16)(nibbles, MSB-first) /:hex(). The loader fills defaults, type-checks each supplied value, and rejects an undeclared or missing-required param — the same signature-is-the-contract stance as the sandbox. Stashed as__evt_paramsso the editor round-trips it. A behavior may also declareproperties:— native Godot properties applied toselfat attach and re-applied on script hot reload; the scene's own property keys always win, and an unknown property name is a load error. Convention: static initial engine state lives in the frontmatter/scene, not the body.*.statemachine.evtis a declarative FSM attached viamachines = [...](or a script'smachines:frontmatter). Frontmattername:(default file stem),states:(required),initial:(default first); the body returns the ordered transition list —{ from, to, when = fn(self) | on = "event" | after = seconds [, run = fn(self, from, to)] }(dois a Lua keyword). Guards are polled per physics tick in order (first match wins, max one transition/tick);:fire("event")is immediate (reentrant fires defer a tick);from = "*"is a wildcard. Behaviors react via the node surfaceself.fsm.<name>—.state,:is(s),:fire(evt),:on_exit(state, fn(to)), andself.fsm.<name>.<state> = fn(from)appends an enter listener. Hot reload: a machine edit keeps state + listeners; a behavior reload drops that behavior's stale listeners (re-subscribe inon_load). (src/Evaluate/StateMachine.cs)- GAS-lite (
src/Evaluate/Abilities.cs): node-attached scripts declareattributes:— per-node pools (base/min/max/regen/regen_delay/recover) with built-in stamina semantics: regen per second afterregen_delaysince the last spend; a spend that drains tominexhausts the pool (tagexhausted:<name>) until it regens back torecover— andabilities:—*.abilityfiles (TOML:cooldown,channeledwith per-second cost,cost = { attribute, amount },tags/block_tags/grant_tags,[[effects]]blocks) granted at attach.*.effectfiles (TOML: dottedattributefield targets like"stamina.max",op = add|mul|set,magnitude,duration0 = instant / −1 = while-active / >0 = timed,periodfor dots) mutate or modify pools. Lua surface:self.attributes.<name>(modifier-aware clamped read; assignment = clamped hard set),:max(n),:has(n);self.abilities:grant/activate/deactivate/is_active/ can_activate/cooldown/has_tag/apply/on_ended..ability/.effectfiles hot-reload live. *.evtsystems are conductors: noscenes:⇒ global (always run);scenes: [a, b]⇒ active only whilea/bis current. ThesceneAPI does routing —scene.change(name[, ctx])(applied at the next frame boundary, never mid-hook;ctxis a table carried to the destination),scene.current(),scene.find(path)(unique by node path, e.g."Level/Enemy"),scene.add(node),scene.list(), plus the scene STACK —scene.push(name[, ctx])overlays a scene (the one beneath freezes: engine processing off, no hooks, still rendered — a pause menu over the live world),scene.pop()thaws it back (controller scenario + possession restore to their push-time values),scene.stack()lists the layers, andscene.context()returns the current transition's context (caller keys +from/to/reason).scene.changeclears the whole stack.- Scene-level
[player]spawner. A scene that wants a possessed player declaresplayer = { node = "<fragment>", spawn = "<module.evt>" }at top level: the single-root fragment scene is instantiated on entry, placed by the module's exportedspawn(ctx) -> { x, y, z, facing? }(ctx = the transition context — a loading zone passes{ entry = ... }throughscene.change), and auto-possessed by the native controller; an optional top-levelscenario = "…"switches the controller scenario on entry. Hot-reload rebuilds of the active scene preserve the live player transform (spawn scripts don't re-run).
- Native input — the PlayerController +
actions. Raw input never reaches scripts: theinputservice andon_inputhook are gone, and the input classes (Input,Key,JoyButton,InputEvent*, …) are blocked fromapis:. Instead the manifest declarescontrols = "…toml"and an implicitPlayerControllernode in the global layer maps devices → ACTIONS: TOML sections are SCENARIOS ([Gameplay],[Menu], the always-active[Always],[settings]for deadzone/threshold), keys are binding tokens (Button_a,Key_space,Stick_left,Axis_trigger_right,Mouse_left; digital keys feed vector axes via"Move+x"suffixes), values are action names. Keyboard layouts: declarelayouts = ["qwerty", "dvorak"]in[settings]and scope bindings with[Scenario.<layout>]sub-blocks; the active layout persists in the save DB (controller.layout(name)/layouts()). Devices are polled once per physics tick (before anyon_physics_update); scripts declareapis: [actions]and useactions.<Scenario>.<Action>—subscribe{ on = "press|release|tap|held", after = seconds, run = fn }(acancel()handle back; hot-reload- and scene-lifetime-safe) or the livedown/value/vectorreads. Thecontrollerapi addsscenario(),possess()/possessed(), save-DB rebinds (rebind/overrides/reset_overrides— acontrol_overridestable applied over the TOML),rumble, andcapture_text(menu typing without raw key access). (src/Evaluate/Controls.cs,src/Evaluate/PlayerController.cs) store— global session state.store.set/get/has/delete/keys(prefix)+store.subscribe(key_or_"prefix.", fn(key, new, old)): values live in the global layer and survive every scene switch/push/pop; nothing touches disk (durable saves staysave/sql). Subscriptions are owner-attributed and die with their scene layer. (src/Evaluate/Store.cs)- Editor preview + write-back (optional addon). A
.scenefile is TOML, which the Godot editor doesn't render natively. Thedev/addons/evaluate_sceneaddon registers anEditorImportPluginthat converts.scene→ aPackedScene(via the sameSceneFile/SceneBuilderthe runtime uses), so the editor shows and renders the node tree. It also adds an "Evaluate" dock (and Tools-menu entries) to open a.sceneas an editable native scene, edit it with the normal editor — move nodes, create and place new nodes, set properties, attach a node script via a__evt_scriptmetadata entry — and Save to .scene, which serializes the edited tree back to the TOML file viaSceneWriter(the inverse ofSceneFile/SceneBuilder). Editor-only (#if TOOLS) and needs no running game (a running game's hot-reload picks the saved file up for free). The addon is not part of the NuGet package (the runtime library carries no editor types); instead it ships as a drop-in zip on each GitHub release (evaluate_scene-<version>.zip): extract it into your project to getres://addons/evaluate_scene/, then enable the plugin in Project Settings (it needs theRadicalBeard.Evaluatepackage for the build/serialize logic). A C# editor addon can't be a bare DLL — its.csmust live underres://addons/and compile with the game — so the zip carries the source; the stableEvaluate.Editornamespace means it drops in verbatim. - Lifecycle hooks.
register:wires Godot's Node lifecycle. System hooks:on_start(global, once),on_load/on_unload(every (re)load / before reload),on_enter/on_exit(scene-scoped, per activation),on_update(dt),on_physics_update(dt),on_focus_in/on_focus_out,on_pause/on_resume(app pause AND scene-stack freeze/thaw),on_quit. Behavior hooks (*.behavior.evt/*.node.evt, withself):on_attach(once, at first attach),on_load/on_unload,on_update,on_physics_update,on_exit,on_quit, plus the same focus/pause pairs. (on_inputwas removed in 0.11.0 — input arrives as mapped actions.) Machines register no hooks — they are polled data. std.*standard library. Real C#-backed types via the[LuaObject]source generator —std.vec3,std.vec2,std.color,std.vector,std.linked_list(src/Evaluate/Std.cs).- Persistence (
save). SQLite-backed runtime/player data in Godot's per-projectuser://directory (the platform-native, per-game path Steam Cloud syncs from) —save.set/get/delete(src/Evaluate/Persistence.cs). - Godot binding (declared class modules). Any Godot class/enum name in
apis:resolves to a bound module table (src/Evaluate/GodotBinder.cs):- instances —
Node3D.new()(Activator); member access routes through the engine ClassDB (GodotObject.Call/Get/Set), not reflection. Instances areILuaUserData, so they pass back into the engine (world:add_child(node)); - properties —
node.position = std.vec3(…)/node.position; - signals —
node:connect("timeout", fn)(0–6 args, dispatched on the main thread) andnode:emit_signal(...); - enums/constants —
Key.Space,Timer.TimerProcessCallback.Idle; - marshalling — exhaustive: primitives, strings,
NodePath, objects,Array/Dictionary; rich C#-backedVector2/3&Color(↔std.*); and every other struct (Vector4,Vector2I/3I,Quaternion,Rect2,Plane,Aabb,Basis,Transform2D/3D) + packed arrays as named-field/list tables. Reads are tables; writes are target-type-aware — assigning a table to a struct property builds the exact struct (sonode.transform = tround-trips); - statics — reflection fallback, or a pre-baked zero-reflection binding.
- instances —
- Pre-bake (source generator).
generator/is a RoslynIIncrementalGenerator:[BindGodot(typeof(Godot.OS))]emitsOsBinding.Create()with direct, reflection-free calls (53 boundOSmethods), filtered to Lua-convertible signatures. The binder prefers pre-baked bindings. - API spec & agent skill (consumer onboarding).
godot --headless --path . -- --emit-api <dir>dumps the entire Lua surface a script sees — read live, so nothing is hand-written or hard-coded: the declarable Godot class modules (methods/properties/signals) come from engineClassDBintrospection, enums/constants/statics + the class set from GodotSharp reflection,std.*from the[LuaObject]types, the capability + host apis by walking the tablesLoaderbuilds, and hooks/frontmatter from the runtime's arrays (src/Evaluate/EvaluateDocs.cs— the runtime analogue of the build-time generator). It emitsevaluate-api.json, LuaCATS---@metafiles for IDE autocomplete (the whole class-module/std.*surface), and a Markdown reference. A pre-generated copy plus a drop-in agent skill (teaching an LLM the script contract) live underdownloads/; each release also shipsevaluate-downloads-<version>.zip.
Base VM
Lua-CSharp (LuaCSharp 0.5.5, Lua 5.2).
Source-generator interop ([LuaObject]) → AOT-clean, low-allocation. Its API is
async-only; we bridge to sync for Godot's _Process (safe because script calls
don't actually await). Dialect note: scripts use x = x + …, not the planned
custom dialect's +=.
Run
godot --headless --path dev # demo: global layer, scene switch (menu -> level1), behaviors
godot --headless --path dev -- --test # enforcement suite (115 tests)
godot --headless --path dev -- --quit-after 8 # demo, then quit after 8 frames
godot --headless --path dev -- --emit-api out/ # dump the full Lua API spec (json + LuaCATS + markdown)
Layout
README.md CHANGELOG.md Directory.Build.props Evaluate.slnx global.json LICENSE.md
src/Evaluate/ Evaluate.csproj EvaluateRuntime.cs Loader.cs Std.cs
Frontmatter.cs Toml.cs SceneFile.cs SceneBuilder.cs SceneWriter.cs
StateMachine.cs Abilities.cs Sql.cs GodotBinder.cs GodotStructCodec.cs
Persistence.cs BindGodotAttribute.cs Prebaked.cs EvaluateTests.cs
EvaluateDocs.cs EvaluateDocsModel.cs EvaluateDocsWriters.cs (--emit-api spec generator)
src/Evaluate.Generator/ Evaluate.Generator.csproj BindGodotGenerator.cs (Roslyn source generator)
downloads/ drop-in resources for consumers: spec/ (generated Lua API spec — json + LuaCATS
+ markdown) and skill/evaluate-scripting/ (a downloadable agent skill)
editors/vscode/ VS Code extension for .evt: YAML+Lua highlighting, frontmatter IntelliSense,
and sandbox-aware Lua-body autocomplete via lua-language-server + the LuaCATS spec
editors/nvim/ Neovim plugin for .evt: the same filetype/highlighting, frontmatter
IntelliSense (completion/diagnostics/hover) and Lua-body lua-language-server wiring
dev/ the demo / enforcement-test harness game (consumes the lib by project reference):
project.godot main.tscn EvaluateHost.cs Dev.csproj
scripts/ global.scene menu.scene level1.scene
player.node.evt enemy.node.evt (per-node params) showcase.evt menu.evt level1.evt
lib/mathx.evt game.toml player.toml
addons/evaluate_scene/ editor import plugin (.scene -> PackedScene preview)
tests/ enforcement-suite fixtures (.evt/.scene/.gdshader) used by EvaluateTests.cs:
forbidden_global / undeclared_api / missing_accessor / readonly_module /
metatable_oop / require_* / scene_* / system_* / self_node / sql_probe /
struct_read + assets/ (glob/hot-reload shaders), + global.scene
artifacts/ packed .nupkgs (gitignored)
Remaining work toward "full featured"
- Godot coverage is complete: instance + static members (any type), all
Variant structs + packed arrays, enums/constants, and signals — all two-way.
Static methods with struct/packed signatures bind via the reflection fallback
(layered over the pre-baked primitives). Instance access is reflection-free
(engine ClassDB
Call/Get/Set). Remaining (perf only): extend the source-gen pre-bake to struct/packed static signatures so they too avoid reflection. - Frontmatter LSP (deferred): editor diagnostics for the signature block —
e.g. red-underline an unknown
apis:entry or missing config file as you type (the loader already rejects both at load). The Lua body itself rides the standard Lua LSP. - AOT export: built (0.12.0) — trim/AOT-analyzer-clean library, embedded
trimming roots, NativeAOT proof harness (
tools/AotSmoke), hot-reload auto-off in exported builds, iOS-safe SQLite (bundle_green), Android/iOS export presets + themobile-smokeCI lane. Consumer guide:EXPORTING.md. Mod packaging/distribution (runtime-loading third-party scripts from outside the PCK): still desktop-only, not yet built. - Out of scope by decision: a Lua type system (signature contract is enough) and sandbox resource limits / DoS protection (modding is free).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- GodotSharp (>= 4.6.0)
- LuaCSharp (>= 0.5.5)
- Microsoft.Data.Sqlite.Core (>= 9.0.0)
- SQLitePCLRaw.bundle_green (>= 2.1.10)
- Tomlyn (>= 0.19.0)
- YamlDotNet (>= 16.2.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.12.0 | 53 | 7/5/2026 |
| 0.11.1 | 52 | 7/2/2026 |
| 0.11.0 | 50 | 7/2/2026 |
| 0.10.2 | 52 | 7/2/2026 |
| 0.10.1 | 40 | 7/2/2026 |
| 0.10.0 | 46 | 7/2/2026 |
| 0.9.0 | 42 | 7/1/2026 |
| 0.8.0 | 90 | 6/30/2026 |
| 0.7.2 | 112 | 6/24/2026 |
| 0.7.1 | 96 | 6/24/2026 |
| 0.7.0 | 101 | 6/24/2026 |
| 0.6.0 | 105 | 6/23/2026 |
| 0.5.2 | 103 | 6/22/2026 |
| 0.5.1 | 106 | 6/22/2026 |
| 0.5.0 | 106 | 6/22/2026 |
| 0.4.2 | 106 | 6/19/2026 |
| 0.4.1 | 101 | 6/19/2026 |
| 0.4.0 | 100 | 6/19/2026 |
| 0.2.0 | 102 | 6/18/2026 |
| 0.1.0 | 106 | 6/17/2026 |