Noodloft.Components.Games 0.0.1-alpha5

This is a prerelease version of Noodloft.Components.Games.
There is a newer version of this package available.
See the version list below for details.
dotnet add package Noodloft.Components.Games --version 0.0.1-alpha5
                    
NuGet\Install-Package Noodloft.Components.Games -Version 0.0.1-alpha5
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Noodloft.Components.Games" Version="0.0.1-alpha5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Noodloft.Components.Games" Version="0.0.1-alpha5" />
                    
Directory.Packages.props
<PackageReference Include="Noodloft.Components.Games" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Noodloft.Components.Games --version 0.0.1-alpha5
                    
#r "nuget: Noodloft.Components.Games, 0.0.1-alpha5"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Noodloft.Components.Games@0.0.1-alpha5
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Noodloft.Components.Games&version=0.0.1-alpha5&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Noodloft.Components.Games&version=0.0.1-alpha5&prerelease
                    
Install as a Cake Tool

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 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 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

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on Noodloft.Components.Games:

Package Downloads
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 87 8/14/2026
0.2.0-beta.8 83 8/13/2026
0.2.0-beta.7 84 8/13/2026
0.2.0-beta.6 79 8/13/2026
0.2.0-beta.5 73 8/13/2026
0.2.0-beta.4 90 8/13/2026
0.2.0-beta.3 81 8/13/2026
0.2.0-beta.2 93 8/13/2026
0.2.0-beta.1 74 8/10/2026
0.1.0 145 8/8/2026
0.0.1-alpha9 126 8/5/2026
0.0.1-alpha8 123 8/5/2026
0.0.1-alpha7 123 8/5/2026
0.0.1-alpha6 121 8/5/2026
0.0.1-alpha5 121 8/5/2026
0.0.1-alpha4 108 8/4/2026
0.0.1-alpha3 110 8/4/2026
0.0.1-alpha2 119 8/3/2026
0.0.1-alpha11 132 8/6/2026
0.0.1-alpha10 122 8/6/2026
Loading failed

Fixes from a review of `v0.0.1-alpha4`. Four of these could lose or corrupt a player's save.

### Fixed

- **A save could be silently downgraded and corrupted.** `SaveSession.Save` merged whatever it found
 in the slot without looking at its schema version, then stamped the result with this build's. A slot
 written by a newer build was read with the parts this build happened to recognise, rewritten as the
 older format, and the rest of it lost — irreversibly. Loading already refused a newer format;
 saving, the more destructive of the two, did not. It now refuses a newer schema unless `Overwrite`
 is explicit, and migrates an older one before carrying its entries forward.
- **A subsequent save erased the slot's metadata.** `options.Metadata` was written unconditionally, so
 the first ordinary autosave after a metadata-bearing one replaced the area name and playtime a load
 menu reads with nothing — despite the format documenting metadata as round-tripped untouched. A save
 that supplies no metadata now carries forward what is already there. Pass an empty dictionary, or
 `Overwrite`, to clear it deliberately.
- **`GodotUserSaveStore` deleted the previous save before renaming the new one into place**, leaving a
 window in which the slot did not exist at all — the exact window a write-then-rename removes, and
 the opposite of what the comment above it claimed. The previous file is now moved aside and only
 removed once the new one has landed; a failed rename puts it back. Renaming straight over the old
 file would be one step instead of three, but only POSIX guarantees that replaces atomically, so the
 short version is safe on the developer's machine and not on half the players'.
- **NaN could reach a health pool and poison it permanently.** `Math.Clamp` passes NaN straight through
 — every comparison against it is false — and NaN survives all later arithmetic, so a single one left
 a pool that could never be healed or damaged again. The evaluator now declines it and returns the
 value unchanged, exactly as it already did for division by zero.
- **Health reported `Applied` for operations that changed nothing.** Healing a full pool, adding zero
 and every operation the evaluator declined all raised `OnModelSet`, and Godot's `HealthChanged`
 behind it, for a pool that had not moved — against the contract the rest of the library is built on.
 They now report `Unchanged`.
- **Disposing an older node could unregister a newer one.** Registration deliberately lets the later of
 two claimants on a save id win, but unregistration removed by id alone. Two scenes overlap during a
 transition — the incoming one is ready before the outgoing one is freed — so the outgoing node took
 the live registration with it and the new scene was silently never saved. `SaveSession.Unregister`
 gained an overload that removes a participant only if it is still the registered one, and the nodes
 use it.
- **The movement input nodes applied two dead zones.** They read `GetActionStrength`, which has already
 applied the action's own dead zone from Project Settings, and the component then applied its
 circular one on top — so a stick had to clear a square threshold and then a round one, and the usable
 range began well past where either was configured. They now read `GetActionRawStrength`.
- **`OrbitCamera3D` only warned about needing physics processing, and did not enforce it.** Avoid
 Geometry casts a ray, which is only safe during a physics frame; a value set from code or a warning
 read and ignored left it running from `_Process`. The node now selects physics processing itself
 whenever Avoid Geometry is on, and keeps the warning for the case somebody can see it.
- **`TickMode.Manual` on the camera nodes had no way to tick them.** Added `ManualTick(float)`,
 matching `PersistentComponentNode`.

### Changed

- **The packages multi-target net8.0 as well as net10.0.** net8.0 is what Godot 4.4 gives a C# project
 by default, so a net10.0-only package failed to install 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, and CI installs both runtimes; running the whole suite locally now needs the .NET 8
 runtime alongside the SDK.

### Added

- 15 regression tests covering every fix above, including a schema-5 slot surviving a save from a
 schema-2 build, metadata outliving a later save, an overlapping registration not being unregistered
 by the node on its way out, and a health pool still working after a NaN was refused.