Noodloft.Components
0.0.1-alpha3
See the version list below for details.
dotnet add package Noodloft.Components --version 0.0.1-alpha3
NuGet\Install-Package Noodloft.Components -Version 0.0.1-alpha3
<PackageReference Include="Noodloft.Components" Version="0.0.1-alpha3" />
<PackageVersion Include="Noodloft.Components" Version="0.0.1-alpha3" />
<PackageReference Include="Noodloft.Components" />
paket add Noodloft.Components --version 0.0.1-alpha3
#r "nuget: Noodloft.Components, 0.0.1-alpha3"
#:package Noodloft.Components@0.0.1-alpha3
#addin nuget:?package=Noodloft.Components&version=0.0.1-alpha3&prerelease
#tool nuget:?package=Noodloft.Components&version=0.0.1-alpha3&prerelease
Noodloft.Components
Engine-agnostic gameplay components for .NET games, with save/load built in and a Godot 4 addon on top.
The gameplay rules — how armor and resistances combine, when a level-up fires, where an item stacks — live in plain C# classes that can be unit-tested without an engine running. The engine layer is a thin shell over them.
Projects
| Project | What it is |
|---|---|
Noodloft.Components |
The reactive component base: ReactiveComponentBase, ReactiveResult, IStatefulComponent, ILogger. Zero dependencies. |
Noodloft.Components.Games |
The seven component families. Zero dependencies. |
Noodloft.Components.Persistence |
Save/load. References the two above; System.Text.Json is in the shared framework. |
Noodloft.Components.Games.Godot |
GodotLogger and GodotUserSaveStore. Compiled, shipped as a DLL. |
Noodloft.Components.Games.Godot.Addon |
The nodes, resources and autoload. Shipped as source — see below. |
The component families
| Family | Owns |
|---|---|
| Health | A pool with a floor and a ceiling. |
| Damage | Crit, armor, typed resistances, invulnerability frames, death, revival. Composes a health pool. |
| Attributes | A modifiable stat: flat, additive-percent and multiplicative-percent modifiers, removable by id or by source. |
| Progression | XP and levels, over a pluggable curve. |
| Cooldowns | One cooldown, or a whole ability bar under a CooldownRegistry. |
| Inventory | Slot-based container with stacking, an optional weight budget, and all-or-nothing or partial operations. |
| Interaction | Focus, hold-to-complete, lock, one-shot. |
| Input | Two components: a buffer (press buffering, hold-to-charge, double tap) and a rebindable action map that saves the player's control scheme. |
The shape every component takes
Model mutable state, with internal setters
DTO a readonly struct describing one operation
Evaluator a pure static function: (DTO, Model) -> result
Component wires them together and raises events
An operation returns one of three outcomes, and the difference matters:
var result = cooldown.SetModel(CooldownOperation.Tick(delta));
result.Outcome switch
{
ReactiveOutcome.Applied => // the model changed; OnModelSet fired
ReactiveOutcome.Unchanged => // legal, but a no-op. Silent: this is the per-frame path
ReactiveOutcome.Rejected => // the caller asked for something impossible. Logged, with a reason
};
Input, specifically
The two halves are split because only one of them is save state.
// Timing. Never saved: restoring a half-held button leaves it stuck down forever.
if (buffer.TryConsume("jump")) // pressed up to BufferDuration ago, and not yet claimed
Jump();
buffer.HoldRatio("charge"); // 0..1, for a charge meter
// Bindings. Saved — but only the actions the player actually rebound, so retuning a
// default control scheme still reaches everyone who left that action alone.
bindings.SetModel(BindingOperation.Rebind("jump", new InputBinding(InputDeviceKind.Keyboard, "Enter")));
A rebind onto a button another action owns is refused and names the clash, which is what a controls
menu needs in order to say "Space is already Jump". InputBindingsNode is the only thing that turns a
stored binding into a real InputEvent, so a save survives an engine upgrade renumbering its keycodes.
Unchanged exists so that ticking sixty idle cooldowns a frame raises no events and logs nothing.
Every reason string on that path is a constant, because an interpolated one would allocate on every
tick of every component — measured at 128 B per tick before it was fixed.
Three words, no overlap
| Word | What it is | Saved? |
|---|---|---|
| Definition | Authored configuration as a plain record. Validate() returns problems; Create() builds a component. |
No |
| Resource | The Godot inspector's editable wrapper around a Definition. [Export]s and a ToDefinition(). |
No |
| Snapshot | Runtime state. | Yes, and only this |
Configuration is never written to a save file. That is what lets a designer rebalance a curve or widen a chest and have it take effect on saves that already exist, instead of being overwritten by them.
Persistence
var session = new SaveSession(new FileSystemSaveStore("saves"), gameVersion: "1.0.0");
session.Register(SaveParticipants.ForDamageable("player", damageable));
session.Register(SaveParticipants.ForInventory("player.bag", bag));
session.Save("slot1");
session.Load("slot1");
Save merges over what is already in the slot rather than replacing it, so saving while only one
scene is loaded does not wipe every other scene's state. Restoring writes models directly and fires no
domain events: loading a dead boss raises no OnDied, loading level 12 raises no eleven level-ups.
The full contract — the JSON shape, the type-id table, the version-mismatch rules and the config-drift table — is in docs/save-format.md.
Godot
Add addons/noodloft.components/ to your project and enable the plugin. That registers the
SaveManager autoload; the nodes and resources appear in Create New Node and Create New Resource
as soon as the project builds.
Drop a node in, point it at a .tres, give it a Save Id, and it is saved:
GetNode<SaveManager>("/root/SaveManager").Save("slot1");
Nodes are gated hard on processing. SetProcess(false) is the normal state — a ready cooldown, a
damageable out of invulnerability frames, an idle interactable — so a level full of components costs
nothing. At 64 actors an idle frame measures 4.8 µs and 0 B allocated.
Why the addon ships as source
Godot registers C# types as scripts keyed by a res:// path, stamped at compile time by
Godot.SourceGenerators. A [GlobalClass] compiled into a referenced DLL has no such path in the
consuming project, so it cannot be attached to a node in the editor and a .tres cannot name it. The
node and resource types therefore ship as source; the engine-independent logic they call stays in the
compiled libraries.
Shipping source also removes GodotSharp version skew: the consumer compiles against their own engine's bindings.
Build
dotnet build Noodloft.Components.slnx -c Release # must stay at zero warnings
dotnet test Noodloft.Components.slnx
dotnet run -c Release --project benchmarks/Noodloft.Components.Benchmarks -- --filter '*FrameLoop*' --job short
Analyzers run at latest-recommended with EnforceCodeStyleInBuild, so the IDE and the command line
agree on what counts as a warning. Nothing is suppressed in src/; the few suppressions that exist
are scoped to a test or benchmark csproj with a written reason.
Releases
Tagging is what publishes. A vX.Y.Z tag is the version — the workflow refuses a tag that is not a
semantic version, and refuses one with no matching section in CHANGELOG.md. That
section becomes both the package's release notes and the body of the Gitea release, so the three can
never disagree.
Below 0.1.0 every tag carries -alpha and the API is explicitly unstable.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- No dependencies.
NuGet packages (4)
Showing the top 4 NuGet packages that depend on Noodloft.Components:
| Package | Downloads |
|---|---|
|
Noodloft.Components.Games
Engine-agnostic gameplay components: health, damage with typed resistances, modifiable attributes, experience and levelling, authoritative socketed item assemblies, source-aware gameplay abilities, cooldowns, slot-based inventory, interaction, input buffering with rebindable controls and movement intent, and camera shake, orbit and follow damping. The rules live in pure evaluators that unit-test without an engine running. No dependencies. |
|
|
Noodloft.Components.Persistence
Save and load for Noodloft components. Source-generated JSON, pluggable stores, per-entry versioning, and a merging save that does not wipe the state of scenes that are not loaded. Configuration is never written to disk, so rebalancing takes effect on existing saves. |
|
|
Noodloft.Components.Games.Godot
Godot 4 glue for Noodloft components: logging, user:// save and encrypted token storage, and the WebSocket relay MultiplayerPeer used by server-authoritative games. The nodes and resources are NOT in this package. Godot keys C# script types to a res:// path, which a type inside a DLL does not have, so they ship as addon source instead. |
|
|
Noodloft.Components.Games.Godot.Arch
Runs the Arch entity component system inside Godot 4: a composable runtime driven from _Process and _PhysicsProcess, a two-way map between entities and nodes, and sync systems that write simulation results back onto the scene tree. Arch is a real archetype ECS — components are flat structs packed into contiguous chunks and walked linearly — so it is worth reaching for exactly when a scene has thousands of similar things and per-node scripts have stopped being affordable. It is not a replacement for nodes, and this package does not try to make it one. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0-beta.9 | 91 | 8/14/2026 |
| 0.2.0-beta.8 | 88 | 8/13/2026 |
| 0.2.0-beta.7 | 87 | 8/13/2026 |
| 0.2.0-beta.6 | 80 | 8/13/2026 |
| 0.2.0-beta.5 | 76 | 8/13/2026 |
| 0.2.0-beta.4 | 92 | 8/13/2026 |
| 0.2.0-beta.3 | 83 | 8/13/2026 |
| 0.2.0-beta.2 | 96 | 8/13/2026 |
| 0.2.0-beta.1 | 78 | 8/10/2026 |
| 0.1.0 | 158 | 8/8/2026 |
| 0.0.1-alpha9 | 140 | 8/5/2026 |
| 0.0.1-alpha8 | 132 | 8/5/2026 |
| 0.0.1-alpha7 | 144 | 8/5/2026 |
| 0.0.1-alpha6 | 134 | 8/5/2026 |
| 0.0.1-alpha5 | 132 | 8/5/2026 |
| 0.0.1-alpha4 | 115 | 8/4/2026 |
| 0.0.1-alpha3 | 118 | 8/4/2026 |
| 0.0.1-alpha2 | 128 | 8/3/2026 |
| 0.0.1-alpha11 | 144 | 8/6/2026 |
| 0.0.1-alpha10 | 134 | 8/6/2026 |
No gameplay code changed in this release. It is entirely about what a consumer of the packages gets.
### Added
- **SourceLink.** The `.snupkg` symbols now carry the commit and a URL to fetch each source file from,
so stepping into the library from a consuming game lands in real source. Without it a symbol package
is line numbers into files nobody can retrieve: the debugger knows the exception came from
`InventoryModel.cs:212` and cannot show line 212. Gitea ships no default host list, so the instance
is named explicitly via `SourceLinkGiteaHost` — omit that and SourceLink builds clean and produces
nothing.
- **A build workflow** (`.gitea/workflows/build.yml`) running restore, build at `-warnaserror`, test
and pack on every push and pull request to `master`. The zero-warning build was previously enforced
only when someone cut a tag, which meant a warning introduced in one month was discovered by the
person trying to release in another. The pack step is there for the failure class that only appears
at pack time, such as declaring a readme without packing the file.
### Changed
- **`PackageReleaseNotes` is now this version's own section of this file**, extracted at pack time,
rather than a link to `CHANGELOG.md` on `master`. A link resolves to whatever the changelog says
when it is clicked, so an old package's notes would eventually describe a version it is not. The
same section becomes the body of the Gitea release.
- **Extraction runs before the build**, so a tag with no matching changelog section fails the workflow
in seconds rather than after the test run — which is what leads to notes written in a hurry to get a
run green.
- `Noodloft.Components.Games`' package description and tags now mention input, which 0.0.1-alpha2
added and they did not.
### Fixed
- The Gitea release body is composed in python rather than a shell heredoc. The changelog is full of
backticked type names, and an unquoted heredoc runs what is inside backticks as commands.