Noodloft.Components
0.2.0-beta.1
See the version list below for details.
dotnet add package Noodloft.Components --version 0.2.0-beta.1
NuGet\Install-Package Noodloft.Components -Version 0.2.0-beta.1
<PackageReference Include="Noodloft.Components" Version="0.2.0-beta.1" />
<PackageVersion Include="Noodloft.Components" Version="0.2.0-beta.1" />
<PackageReference Include="Noodloft.Components" />
paket add Noodloft.Components --version 0.2.0-beta.1
#r "nuget: Noodloft.Components, 0.2.0-beta.1"
#:package Noodloft.Components@0.2.0-beta.1
#addin nuget:?package=Noodloft.Components&version=0.2.0-beta.1&prerelease
#tool nuget:?package=Noodloft.Components&version=0.2.0-beta.1&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 twelve 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 |
Logging, user:// saves and encrypted identity tokens, node lookups, and the relay multiplayer peer. Compiled, shipped as a DLL. |
Noodloft.Components.Games.Godot.Addon |
The nodes, systems, resources and autoload. Shipped as source — see below. Needs all four packages, including .Arch. |
Noodloft.Components.Games.Godot.Arch |
Separate package, separate audience: runs the Arch ECS inside Godot. Depends on nothing else here. |
The component families
| Family | Owns |
|---|---|
| Health | A pool with a floor and a ceiling. |
| Damage | Both halves. DefaultAttackEvaluator turns power, strength and a crit roll into a number; DefaultDamageEvaluator decides what the target keeps of it — crit, armor, typed resistances, invulnerability frames, death, revival. Composes a health pool. Neither one owns a random number generator. |
| 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. |
| Skills | Many named progressions under one SkillSet, each paying out SkillRewards into an AttributeSet. Rewards are re-derived from current level, never accumulated. |
| Items | Base stats, rarity, reforges and skill requirements. An ItemCatalog of definitions, an ItemInstanceStore for the copies that have an identity, and an EquipmentSet that applies what is worn. |
| Loot | Weighted and independent tables, Magic Find and Gathering Fortune, both opt-in per entry. Takes an IRollSource, so a table and a seed always produce the same drops. |
| 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.
The addon source references all four packages, Noodloft.Components.Games.Godot.Arch included —
NetworkedEntityNode links a scene node to an Arch entity. A game that uses none of the networking
still needs the reference for the addon to compile.
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.
When you actually want an ECS
Noodloft.Components.Games.Godot.Arch is a separate package that runs the
Arch ECS inside Godot: a Node that owns the World and drives
ordered system stages, a two-way entity↔node map with a resolution cache, and sync systems that write
results back onto the scene tree. It builds on the Godot glue package for engine integration.
That is the real thing — flat structs in contiguous chunks, walked linearly — and it is worth reaching for in one specific situation: thousands of similar things that per-node scripts can no longer afford. Bullets, particles with gameplay meaning, a crowd. Not the player, not a boss, not a chest: those are individually interesting, want editor authoring, and there are twelve of them.
If the goal is tidier code rather than ten thousand affordable things, the systems layer above is the answer and it keeps your nodes. See docs/arch.md.
The package also contains the server-authoritative ECS protocol under .Arch.Net: stable network
IDs, compact codecs, spawn/despawn/input/snapshot systems, wrap-safe interpolation, and a
GodotNetTransport bridge over SceneMultiplayer custom packets. The addon supplies
NoodloftSessionNode, RelayClientNode, and NetworkedEntityNode; component state follows this one
snapshot path and must not also be assigned to a MultiplayerSynchronizer.
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 | 98 | 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 |
0.1.0 gave a game the ability to hurt things. It had no answer for why a player would want to. This
release is the loop that sits on top of the damage seam: skills that level, items that carry stats and
a rarity, loot tables that decide what falls out of a monster, and — the piece that was actually
missing — the half of damage that produces a number rather than reducing one.
Two rules run through all of it, and they are the same rule. **Derived, never accumulated**: skill
rewards and equipment stats are dropped by source and re-added from current state, because adding a
modifier per level-up passes every test that only counts upwards and then doubles a character on their
first load. And **no random number generator inside an evaluator**: attacks take the roll as a
parameter, loot takes an `IRollSource`, so both are pure functions of their inputs and a client and a
server given the same seed agree about what happened.
The networking layer from the relay work also lands here, undocumented until now.
Tagged `-beta.1` for a dependency reason rather than a confidence one. `Noodloft.Components.Games.Godot`
now exposes `GodotTokenStore : ITokenStore` and so depends publicly on `Noodloft.Identity.Client`,
which has only ever published `0.0.1-alpha1`. A stable package depending on a prerelease is NU5104,
and it hands consumers a dependency that can be unlisted out from under them. The label comes off when
that package goes stable; nothing else about this release is waiting on anything.
### Added
- **Skills.** `SkillSet` stands to `ProgressionReactiveComponent` exactly as `AttributeSet` stands to
`AttributeReactiveComponent`: a keyed container over real progressions, one save entry for the whole
set, and `OnSkillLevelUp(key, level)` re-raised with the key. A `SkillDefinition` carries
`SkillReward`s — "+2 Armor per Mining level" — applied to an `AttributeSet` under the per-skill
source `skill:{Key}`.
Rewards are **derived, never accumulated**. `ApplyRewards` drops each skill's modifiers by source and
re-adds them from the current level, so calling it once, three times, or after a load produces
identical stats. Adding a modifier per level-up instead passes every test that only goes up, then
doubles a character's stats the first time a save is loaded.
In the addon: `SkillSetNode`, `SkillSetResource`, `SkillEntryResource`, `SkillRewardResource`.
- **The offensive half of damage.** `DefaultDamageEvaluator` starts from an amount and asks what the
target keeps; `DefaultAttackEvaluator` produces that amount in the first place, as
`(Power + Strength × StrengthFlatShare) × (1 + Strength / StrengthDivisor)`. `AttackerView` mirrors
`DamageableView`, `IAttackSource` mirrors `IMitigationSource`, and `AttributeAttackSource` reads the
stats live so a buff landing between two swings reaches the second one. `AttackOptions.StrengthFlatShare`
defaults to 0, making Strength a clean linear multiplier; `0.2` reproduces Skyblock's quadratic
`(base + Strength/5) × (1 + Strength/100)`.
No random number generator anywhere in it — `Attack` takes the roll as a parameter, so a hit is a
pure function of its inputs and resolves identically on a client and a server given the same roll.
In the addon: `AttackerNode`, `AttackResource`.
- **Loot tables, Magic Find and Gathering Fortune.** A new `Loot` family — `LootTable`, `LootEntry`,
`LootContext`, `DefaultLootEvaluator` — plus `LootTableResource` and `LootEntryResource` in the addon.
Two table kinds: `Independent` (each entry rolls its own chance — a monster's drop list) and
`Weighted` (N picks from a pool — a chest).
**Magic Find is multiplicative and opt-in per entry.** Multiplicative so the stat is worth the same
proportion at both ends of the scale; opt-in so it raises the rare drop rather than the guaranteed
junk, which would otherwise make it mostly a way to get more junk and dilute what it was bought for.
**Gathering Fortune's fractional part is a chance, not a truncation.** 100 Fortune is exactly one
more of everything and 30 is a thirty percent chance of one more — truncating instead would make
every amount below 100 worth precisely nothing. Also opt-in, because 2.4 legendary swords is not a
thing anybody designed.
A reforged drop is always a single copy: a stack of five that shared one reforge is the bug that
forces it.
- **`SeededRollSource`** — the evaluator takes an `IRollSource` rather than calling `Random.Shared`, so
loot is a pure function of table and seed. Deliberately **not** `System.Random`, whose sequence is
documented as unstable across .NET versions: a save that recorded a seed would produce different loot
after a runtime upgrade, and a client and server on different patch levels would disagree about what
dropped. This is xorshift128 written out in full, seeded through SplitMix32 — seeding only the first
word looks like it works and does not, because the early output is dominated by the untouched
constants and seeds 1 through 20 produce the same sequence.
- **Items: base stats, rarity, reforges and skill requirements.** A new `Items` family — `ItemDefinition`,
`ReforgeDefinition`, `ItemRequirement`, `ItemCatalog`, `ItemInstanceStore`, `EquipmentSet` — plus
`ItemResource`, `ReforgeResource`, `ItemCatalogResource` and `EquipmentNode` in the addon.
Stats are derived and idempotent, same rule as skills: `ApplyStats` drops each slot's modifiers by
source and re-adds them from what is worn now. One source per slot (`equipment:weapon`), because
`RemoveModifiersFrom` works by source and sharing one would make re-deriving the weapon wipe the
boots.
Rarity scales reforges and nothing else. A Legendary sword is strong because its own stats say so;
making rarity multiply base stats too means every balance change has to be made twice.
`ItemDefinition.Value` is the item's worth in the abstract, not a price. A vendor applies its own
buy and sell margins to it, so an item that four shops stock is one number rather than four that
drift apart, and the gap between the two margins is where an economy lives — close it and buying
then selling becomes a free-money loop.
`ItemDefinition.Validate()` rejects a reforgeable stackable, and `ItemCatalog.Validate()` rejects a
reforge restricted to a slot no item uses. Both look entirely correct in an inspector and do nothing
at runtime.
- **`ItemStack.InstanceId`** — **breaking**, and the thing every other item feature needed. A stack with
an identity never merges with anything, so two Heroic swords and one plain one are three stacks
rather than one. `InventoryOperation.AddUnique` / `RemoveUnique` name a specific copy; plain
`Remove(itemId, n)` now **skips copies that carry an identity**, so a recipe asking for "a sword"
cannot consume the reforged one the player is wearing.
The identity is opaque and lives on `ItemInstanceStore`, not on the stack. A stack lives in an array
of two hundred, most of them empty; hanging a reforge off each one turns a grid of value types into a
graph of allocations for a property almost nothing uses.
`InventorySlotSnapshot` gained `InstanceId`. Old saves load — the field defaults to null — but a save
written by 0.1.0 has no identities to lose.
- **`AttackTiming`.** Attack speed is not a number a swing uses — it is a number *everything in* a
swing uses. A melee attack has at least four durations: the animation, the wind-up before damage, the
root, and the combo window. Scale three and the fourth desynchronises, and it never crashes — damage
simply lands after the animation ended.
So it is a type rather than a float. `Scale(seconds)` and `AnimationSpeed` come from one `Rate` by
construction, which is the only way "the hitbox agrees with what is on screen" stops being something
a caller has to remember. Durations divide, rates multiply.
`MaximumAttackRate` (4 by default) is load-bearing rather than defensive: far enough up the curve a
wind-up rounds below one frame, the hitbox turns on and off inside a single physics step, and the
attack stops hitting anything. The cap makes attack speed a stat that stops helping instead of a
weapon that stops working.
- **`DamageOperation.CriticalMultiplier`.** Crit damage belongs to whoever swung. A rogue with 250%
crit damage hits everything for 250%; reading it off the target instead makes the same swing land
differently depending on which monster it hit. Null keeps the existing `DamageableOptions.CriticalMultiplier`
behaviour, so authored hits with no attacker behind them are unaffected.
- **`StatNames`.** Constants for the stat keys the library's own adapters look for. `AttributeSet` keys
are ordinal, so `"critchance"` against a registered `"CritChance"` is not a lookup failure — it is a
stat that reads zero forever, and zero looks plausible for most of them.
- **`PersistentComponentNode.OnRestored()`.** Restoring is deliberately silent: loading a character at
Mining 41 raises zero level-ups. Correct, and it meant derived state had no way to hear about a load
— the levels came back and the stats they paid for did not. Every persistent node gets the hook.
- **Cloud saves.** `SyncedSaveStore` writes locally and mirrors to a server on a background worker, so
a save never costs a frame and a player with no connection keeps playing. Uploads coalesce per slot:
autosaving every 45 seconds while offline queues one pending write, not forty. Reads are local only;
call `PullAsync()` once after sign-in. `HttpRemoteSaveBackend` speaks four routes and takes a token
callback rather than a token, so refresh is somebody else's problem.
`SyncedSaveStore.PushAsync()` queues every local save the server has no newer copy of. This is what
rescues a write the last session made but quit before uploading — without it that save waits on disk
until the player happens to write the same slot again, which for a finished playthrough is never.
`NoodloftSaveManager` carries `CloudBaseUrl` and a `CloudAccessToken` callback, and
`NoodloftSessionNode.MirrorSavesToApi` wires the two together — the session node is the only thing
that owns a token.
Conflicts are last-write-wins by `SavedAtUtc`. Stated plainly because it is a real limitation: two
devices playing offline will lose one of the two sessions.
- **Server-authoritative networking for the Arch adapter.** A binary replication protocol under
`Noodloft.Components.Games.Godot.Arch/Net/`, sitting on the same ECS the swarm already runs on.
`NetworkRole` is `Standalone`, `Server` or `Client`, and the systems are split along it:
`NetSnapshotSendSystem` / `NetSnapshotApplySystem`, `NetInputSendSystem` / `NetInputApplySystem`,
`NetSpawnSendSystem` / `NetSpawnSystem`, and `NetInterpolationSystem`, which smooths remote positions
on the rendered-frame stage rather than the fixed one.
The wire format is authored, not reflected. `INetComponentCodec` owns one component slice under a
stable `TypeId` byte — `Position2DCodec`, `Velocity2DCodec`, `HealthCodec`, `FactionCodec`,
`AnimatedCodec` — registered in a `NetComponentRegistry`, and `SnapshotMessages` fixes the payload
ids and header sizes as constants. `NetworkWriter` and `NetworkReader` are non-throwing little-endian
`ref struct`s over caller-owned memory: a truncated or hostile packet returns false rather than
throwing inside a receive loop.
`NetSpawnRegistry` maps an authored kind byte to the archetype and factory that builds it, so a
client constructs an entity it has never seen a class for. `NetworkEntityMap` keeps remote ids
separate from local `Entity` handles, and registering the same kind twice throws at registration
rather than producing two readings of the same byte at runtime.
- **`RelayMultiplayerPeer`** — a `MultiplayerPeerExtension` that adapts Noodloft's WebSocket relay
framing to Godot's own multiplayer API, so `SceneMultiplayer` and everything built on it works
unchanged over the relay. Transfer mode and channel are accepted and retained because the Godot API
is expected to behave normally, but WebSocket delivery is always reliable and ordered and they cannot
loosen that; a lower-latency transport belongs behind this same contract.
`GodotNetTransport` is the single bridge between Godot's custom-packet layer and the ECS protocol.
Component state stays owned by the snapshot pipeline and is never also put on a
`MultiplayerSynchronizer` — two mechanisms replicating one value is two mechanisms disagreeing about
it.
In the addon: `RelayClientNode` (host/join lifecycle), `NoodloftSessionNode` (sign-in, and the only
thing that owns a token) and `NetworkedEntityNode`, which links an authored scene node to the
networked entity driving it. `NodeCaptureSystem2D` reads an authoritative `CharacterBody2D`'s
transform into ECS; `NodeSyncSystem2D` writes the other way for remote entities. Which direction a
transform flows is a property of the entity, not something each system guesses.
- **`GodotTokenStore`** — encrypted token storage over `user://`, keyed on Godot's device identifier
combined with a random per-install seed. Web exports expose no device identifier, so there the
install seed is the whole of it. This keeps credentials off disk in plain text; it is not a
replacement for a platform keychain, and any platform that has one should use it instead.
- **Documentation** — `docs/networking-blueprint.md` for the protocol and the role split.
### Changed
- **The supported engine range is now `[4.4.0, 5.0.0)`** rather than exactly `4.4.0`. The pin was
meant to stop NuGet resolving a GodotSharp newer than the engine a consumer actually runs, and it
did — along with stopping anyone on 4.5 or later from restoring at all.
- **The Godot addon now needs `Noodloft.Components.Games.Godot.Arch`.** `NetworkedEntityNode` links a
scene node to an Arch entity, so the addon source no longer builds against the three core packages
alone. Games that unpack `addons/` and use none of the networking still need the package reference
for the addon to compile.
- **`Noodloft.Components.Games.Godot` takes a public dependency on `Noodloft.Identity.Client`**, which
arrives transitively in any project referencing it. It is on nuget.org, so nothing extra is needed to
restore it.