Noodloft.Components
0.0.1-alpha8
See the version list below for details.
dotnet add package Noodloft.Components --version 0.0.1-alpha8
NuGet\Install-Package Noodloft.Components -Version 0.0.1-alpha8
<PackageReference Include="Noodloft.Components" Version="0.0.1-alpha8" />
<PackageVersion Include="Noodloft.Components" Version="0.0.1-alpha8" />
<PackageReference Include="Noodloft.Components" />
paket add Noodloft.Components --version 0.0.1-alpha8
#r "nuget: Noodloft.Components, 0.0.1-alpha8"
#:package Noodloft.Components@0.0.1-alpha8
#addin nuget:?package=Noodloft.Components&version=0.0.1-alpha8&prerelease
#tool nuget:?package=Noodloft.Components&version=0.0.1-alpha8&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, GodotUserSaveStore and the allocation-free node lookups. Compiled, shipped as a DLL. |
Noodloft.Components.Games.Godot.Addon |
The nodes, systems, 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 | Three components: a buffer (press buffering, hold-to-charge, double tap), a rebindable action map that saves the player's control scheme, and movement intent (circular dead zone, snapping, ramping). |
| Camera | Trauma-based shake, an orbit rig with clamped pitch and zoom, and frame-rate-independent follow damping. |
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.
Movement intent is the third piece — the one usually written inline in a character script and usually written slightly wrong:
movement.Set(rawX, rawY); // straight off the device, before any dead zone
movement.Intent; // ramped: what the character moves along
movement.RawIntent; // unramped: what a dodge or a dash fires along
The dead zone is a circle across the pair, not a threshold per axis. With a per-axis dead zone of 0.2, a stick pushed exactly diagonally to 0.19 on each axis reads as nothing — while its true magnitude is 0.27, well past the threshold. That is how a worn controller ends up drifting diagonally while nobody is touching it. Everything past the dead zone is rescaled from 0, so crossing it is a nudge rather than an instant fifth of full speed.
Camera
shake.Add(0.4f); // trauma, 0..1 — adds rather than replacing
shake.Offset; // where to displace the camera this frame
orbit.Look(dx, dy); // device units; sensitivity, inversion and clamping are the component's
orbit.TargetYaw; // what a character should face — leads the camera's own angle
Trauma, not "play a shake for 0.3 seconds". A duration-based shake restarts on every hit, so a machine gun produces one permanent small shake instead of a build-up, and two sources at once means whichever fired last wins. Trauma adds, saturates at 1, and drains from wherever it reached. The displacement is trauma squared, so the tail of a shake fades out instead of leaving the camera buzzing at low amplitude — which reads as a rendering fault rather than an impact.
Follow damping is a critically damped spring rather than the lerp(current, target, rate * delta)
every tutorial reaches for. That one is frame-rate dependent: the same rate lands somewhere else at
144 fps than at 60, so a camera tuned on the developer's machine is wrong on the player's.
The camera nodes are the camera rather than driving one, so exactly one thing writes the transform
per frame. Follow and shake as separate scripts is the classic way to get a camera that jitters
because both assign to Position and whichever runs last wins.
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
NoodloftSaveManager 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<NoodloftSaveManager>("/root/NoodloftSaveManager").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.
Nodes hold state; systems hold rules
A node answers what is this thing's health. It cannot answer may this attacker damage that target, because that is a question about a pair and neither node owns it. Answering it inside every weapon script is how a game ends up with six copies of the friendly-fire rule and five of them updated.
var result = damageSystem.Hit(bullet.Shooter, target, 25f, DamageType.Physical);
if (result.Landed) SpawnHitNumber(result.AppliedAmount, result.WasFatal);
else if (result.WasBlocked) bullet.PassThrough(); // a teammate: do not consume the shot
Blocked is deliberately not Rejected. Your rules refusing a hit is the common case — a hitbox
resting against a teammate refuses on every physics frame — and it must not read as something going
wrong.
| System | Does |
|---|---|
DamageSystem |
Runs composable DamageFilter resources over a hit, applies it, announces what happened. |
TickSystem |
Ticks many component nodes from one frame callback instead of one each. |
InteractionSystem2D / 3D |
Picks which of several things in range should hold focus, with hysteresis so the prompt does not strobe. |
Rules ship as Resources — SelfDamageFilter, GroupDamageFilter, DeadTargetFilter — so turning
friendly fire on for one arena is a .tres edit. Teams are Godot's own groups; this library ships no
team component, because the engine already has a better one and two sources of truth would only
disagree.
This is not an ECS and does not claim to be. An ECS is fast because components are flat structs in
contiguous arrays walked linearly; these are class instances on scene-tree nodes, and systems on top
change nothing about that. What the split does buy is real and is two things: rules in one editable
place, and fewer engine crossings. Every _Process override is a marshalled call from C++ into managed
code, per node, per frame — two hundred actors with a live cooldown are two hundred crossings, or one
plus a C# loop under a TickSystem. Gating decides how much work happens; batching decides how often
the engine has to cross into C# to do it.
The full contract — the node API, the filter interface, the recipes and the pitfalls — is in docs/godot-guide.md.
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
The packages multi-target net8.0 and net10.0. net8.0 is what Godot 4.4 gives a C# project by
default, and a library that only shipped net10.0 would fail to install there with NU1201 — in the very
engine version the addon names as its floor. The test projects target both, so the net8.0 asset is
executed rather than merely compiled; running the whole suite locally therefore needs the .NET 8
runtime installed alongside the SDK. Without it, dotnet test -f net10.0 runs the net10.0 half.
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 | 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 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.
-
net8.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 |
The nodes were closed. Anything outside a node's own scene could reach a component's state but not
operate on it usefully, and there was nowhere for a rule that spans two entities to live. This release
opens the node API and adds a systems layer above it.
### Added
- **A systems layer.** A system is a node that operates on components it does not own. Ships as source
beside the nodes, so forking one is the expected way to make it match a game rather than a failure.
This is **not an ECS**, and the distinction decides what to expect from it. An ECS is fast because
components are flat structs in contiguous arrays walked linearly; these components are class
instances hanging off scene-tree nodes, and systems above them change nothing about that layout.
Two things the split does buy, both real: rules that span entities live in one editable place, and a
system that ticks a list in one loop replaces N per-node `_Process` callbacks, each of which is a
marshalled call from the engine's C++ loop into managed code.
- **`DamageSystem`.** The one place a hit passes through. Runs composable filters over it, applies it,
and reports back a `DamageDeliveryResult` with four outcomes rather than a discarded `void`.
`Blocked` is deliberately not `Rejected`. A hitbox resting against a teammate refuses on every
physics frame, so a game's own rules refusing a hit is the common case and must not read as something
going wrong. Only `Rejected` — no target, a freed target, an amount that is not a number — is worth
logging.
- **`DamageFilter` resources: `SelfDamageFilter`, `GroupDamageFilter`, `DeadTargetFilter`.** Rules
authored as `.tres` rather than compiled in, so turning friendly fire on for one arena is a data
edit. A custom rule is one overridden method returning `null` to allow or a reason to refuse.
Teams are Godot's own groups. This library ships no team component on purpose: groups are authored
in the inspector, free to test, and a node can be in several at once — which is what a game needs the
day "undead" and "player's summons" have to overlap. A second concept beside them would only give a
game two sources of truth to keep in step.
- **`TickSystem`.** Ticks many component nodes from one frame callback. Composes with the existing
per-node gating rather than replacing it: `ManualTick` still checks `NeedsTick`, so two hundred
tracked cooldowns of which three are running do three operations. The gating decides how much work
happens; this decides how many times the engine has to cross into C# to make it happen — which is
why it earns its keep at scale and not below it. `TakeOverTicking` switches each node it adopts to
`TickMode.Manual` and puts it back on removal, because a node left on `Process` is ticked twice a
frame and its cooldowns run at double speed.
- **`InteractionSystem2D` and `InteractionSystem3D`.** Which of several things in range should hold
focus. The choice is arithmetic and lives in the new pure `InteractionTargeting`, so it is tested
without an engine running; the nodes only supply distance and facing.
It has hysteresis, which is the whole reason it is worth writing down. Two chests side by side score
within a rounding error of each other, and an idle animation is enough to swap which one wins — so
without a sticky bonus the prompt strobes, both interactables raise focus and unfocus events every
frame, and a hold-to-interact in progress is cancelled the moment focus moves. Physics query order is
not deterministic either, which is the second reason.
Candidates are pushed in from an `Area2D`/`Area3D`'s own signals rather than discovered: the engine
already tracks overlaps well, and re-querying the scene every frame would cost more than it saves.
- **`Apply(TDto)` on every component node**, returning the `ReactiveResult` instead of discarding it.
The convenience methods are now sugar over it and return results too. Before this, the only way to
reach `DamageOperation.BypassMitigation` from a node was not to use the node.
- **Allocation-free node lookups** in `Noodloft.Components.Games.Godot`: `FindComponent`,
`FindSiblingComponent`, `FindComponentInAncestors`, `FindComponentInDescendants`, `FindComponents`,
`IsInAnyGroup`, `SharesAnyGroup`. The obvious `GetChildren().FirstOrDefault(x => x is T)` builds a
marshalled copy of the child list plus a LINQ enumerator and a closure on every call, and written
into a damage path that runs on every hitbox overlap of every actor.
- **`DamageableReactiveComponent.LastReport`**, in the same idiom as
`InventoryReactiveComponent.LastChangeSet`. It lets a system return the resolved numbers as a value
instead of subscribing and unsubscribing around every hit, which would allocate a delegate per swing.
- **`PersistentComponentNode.SetTickMode`**, which changes the mode *and* refreshes processing.
Assigning `TickMode` alone only takes effect at the next `UpdateProcessing`, which for a node with
nothing to do right now is never.
- **[docs/godot-guide.md](docs/godot-guide.md)** — the node API, systems, filters, recipes and the
pitfalls, including the two mistakes below.
### Changed
- **`Component` is no longer nullable on any node.** It is built on first touch rather than only in
`_Ready`, so a system that reaches a node before the engine has readied it gets a working component
instead of a `null` that every call site swallows with `?.` — which is the shape that silently drops
operations and gives no hint that it did. It throws in the two places it genuinely cannot exist:
while the scene is being edited, and once the node is queued for deletion. Guard `[Tool]` code with
the now-public `HasComponent`.
- **Node methods return their results.** `DamageableNode.Hit`, `Heal`, `Kill` and `Revive`,
`HealthNode.Damage` and `Heal`, `InteractableNode.Focus`/`Cancel`/`SetPrompt` and the rest previously
returned `void` and threw away the reason a refusal happened.
- **`ManualTick` is gated and returns whether it did anything**, so a batching system gets the same
collapse a per-node `_Process` gets for free.
### Fixed
- **`CooldownSetNode.Registry` and the node convenience properties no longer read as empty before
`_Ready`.** They shared the nullable-component problem: `IsReady`, `CountOf`, `IsMoving` and friends
returned a default rather than a value, which is indistinguishable from a real answer.