Noodloft.Components.Games.Godot.Arch 0.1.0

There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package Noodloft.Components.Games.Godot.Arch --version 0.1.0
                    
NuGet\Install-Package Noodloft.Components.Games.Godot.Arch -Version 0.1.0
                    
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.Godot.Arch" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Noodloft.Components.Games.Godot.Arch" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Noodloft.Components.Games.Godot.Arch" />
                    
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.Godot.Arch --version 0.1.0
                    
#r "nuget: Noodloft.Components.Games.Godot.Arch, 0.1.0"
                    
#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.Godot.Arch@0.1.0
                    
#: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.Godot.Arch&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Noodloft.Components.Games.Godot.Arch&version=0.1.0
                    
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, 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.
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 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.

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 references nothing else in this repo.

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.

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

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.2.0-beta.9 70 8/14/2026
0.2.0-beta.8 63 8/13/2026
0.2.0-beta.7 65 8/13/2026
0.2.0-beta.6 59 8/13/2026
0.2.0-beta.5 63 8/13/2026
0.2.0-beta.4 74 8/13/2026
0.2.0-beta.3 66 8/13/2026
0.2.0-beta.2 70 8/13/2026
0.2.0-beta.1 58 8/10/2026
0.1.0 112 8/8/2026
0.0.1-alpha9 94 8/5/2026
0.0.1-alpha11 88 8/6/2026
0.0.1-alpha10 86 8/6/2026

The damage seam was one-directional. Simulated things could hurt nodes; nodes could not hurt simulated
things, and every rule a game plays by — friendly fire included — stopped applying the moment a target
stopped being a node. This release closes that, which costs two breaking changes and buys one set of
combat rules for everything on screen.

It is also the first release numbered honestly. Eleven of them at `0.0.1-alphaN` put every bit of
information in the pre-release label and none in the number.

### Fixed


- **`ArchWorldNode` never ran its `PhysicsProcess` stage, and never had.** `Noodloft.Components.Games.Godot.Arch`
 referenced `GodotSharp` but not `Godot.SourceGenerators`, so the class had no generated
 `HasGodotClassMethod` bridge. Godot decides whether to call a script's frame callbacks by asking the
 script whether it has the method, and with no bridge the answer for `_PhysicsProcess` was no — the C#
 override existed and the engine called it zero times.

 Nothing reported it. There was no missing member, no exception and no warning: the `Process` stage
 worked, because a subclass that overrides `_Process` gets a bridge from its own project's generator,
 so a swarm would draw and animate normally while every physics system behind it — motion, steering,
 collision, contact damage, proxy sync — sat dead. A subclass that overrode `_PhysicsProcess` itself
 was unaffected, which is why the sample in the class documentation worked.

 Fixed by referencing the generator, with `ScriptPathAttribute` disabled — this is a library reached
 by subclassing, not a folder of scripts, and it has no `res://` path to stamp. `GodotMethodBridgeTests`
 now asserts the bridge and every frame callback in it, because a csproj edit is all it takes to lose
 this again and nothing else in the build can see it.

 Caught before this ever shipped, so there is nothing to upgrade from — the only build of
 `ArchWorldNode` anyone can reference is the one that works.

### Changed — breaking

- **Armor is now a percentage with diminishing returns, not a flat subtraction.**
 `DefaultDamageEvaluator` resolves a hit as `(raw × crit) × (constant / (constant + armor)) ×
 (1 − resistance)`, where the constant is `DamageableOptions.ArmorConstant` (default 100) and is by
 definition the armor value that halves a hit. `ArmorMitigation.Flat` restores the old behaviour on any
 damageable that wants it.

 Flat armor is worth the same *absolute* amount against every hit, which makes it total against a
 swarm and negligible against a boss — the opposite of what a defensive stat is meant to express, and
 a cliff rather than a curve at the point where armor exceeds the incoming damage. The curve is worth
 the same *fraction* of your effective health per point, never reaches immunity however much is
 stacked, and is what almost every game means by armor.

 **Existing armor values have to be re-read against the new scale**: under the old rule 15 armor
 erased 15 damage, and now it takes off thirteen percent. Negative armor — a sunder, a shred —
 amplifies along the mirrored curve, bounded at exactly double damage and continuous through zero, so
 a debuff ticking down past it produces no step.

- **`IMitigationSource` is now a single-member producer**: `MitigationValues For(DamageType)` replaces
 the `Armor` property and the `ResistanceTo` method. `NoMitigation`, `StaticMitigation` and
 `AttributeMitigation` are updated; the latter two keep `Armor` and `ResistanceTo` as their own
 members, so only code written against the *interface* has to change.

 The two-member shape only implied that mitigation resolves per hit per type. One member states it,
 and it lets an implementation read its backing store once where the split forced it to read twice.
 A default interface method would have preserved compatibility here, and it was rejected: the point
 was to change the shape, and leaving the old one reachable would have left two ways to ask.

- **`DamageFilter.Veto` now takes a `DamageContext`** instead of
 `(Node?, DamageableNode, DamageOperation)`. Rewrite a custom filter as
 `public override string? Veto(in DamageContext context)` and read `context.Source`,
 `context.TargetNode`, `context.Target` and `context.Operation`.

 The old signature assumed every combatant was a node. A rule that genuinely needs node identity —
 scene ownership, Godot groups — should check `context.HasTargetNode` and **allow** the hit when
 there is none. Refusing instead makes every entity invulnerable the day a game adds that filter, and
 the symptom is a swarm that cannot be killed rather than an error anyone can search for.

- **`LifetimeSystem` and `ProjectileSystem2D` now take an `ArchDestructionQueue`.** Both destroyed
 entities themselves, each with its own private list, which meant the one place that has to free an
 entity's physics handles was several places and any of them could be the one that forgot.

- **Removed `MotionSystem3D`, `NodeSyncSystem3D`, `Position3D` and `Velocity3D`.** Pure speculation in
 a 2D-only codebase, costing compile time and documentation surface for nothing. They come back the
 day something 3D needs them, which is a smaller job than keeping them warm.

### Added

- **`MitigationValues`** — `readonly record struct (float Armor, float Resistance)`. A hit has exactly
 one damage type, so resolving one never needs a resistance *table*, and collapsing it to two floats
 is what lets a struct entity carry mitigation at all.

- **`FactionId`** — which side something is on, as a value small enough for anything to hold. Godot
 groups remain the better answer for nodes; this exists for combatants that are structs in an array
 and can never be in a group. Deliberately just an identity: relations belong in a filter, not in a
 matrix this type would have to serialize.

 One rule is worth stating because it surprises people: **unaligned is not a side.** Two things that
 both forgot to set a faction are not allies, because treating them as one blocks every hit in a game
 that has not finished wiring factions up.

- **`DamageableView`** — everything resolving a hit needs to know about its target, flattened so the
 target can be an object *or* a struct in an array. `DefaultDamageEvaluator.Resolve` now has an
 overload taking one, and it is the implementation; the `DamageableModel` overload builds a view and
 calls it. One copy of the arithmetic for a sword and for a swarm.

 A plain `readonly record struct`, not a `ref struct`: nothing here borrows a reference to the health
 it describes, so the extra restrictions would be paid for nothing.

- **`DamageContext` and `DamageVerdict`** in `Noodloft.Components.Games.Godot`, plus
 **`IDamageRouter.Judge`**. `Route` lets a simulated thing damage a node; `Judge` runs the same rules
 against a target that is not one and reports what *would* happen without changing anything. It
 judges rather than applies because an entity's pool is a float in a chunk that its own system owns.

- **`FactionNode`** and **`FactionDamageFilter`** in the addon. The friendly-fire rule that works when
 some combatants are not nodes. `GroupDamageFilter` is still the better choice for a game with no
 simulated ones.

- **`DamageableNode.ToView(DamageType)`** — the node's own numbers in the shape the rules read.

- **Arch combat components**: `Health` (a struct pool with `TryApplyDamage`), `Faction`, `Mitigation`
 and `Invulnerable`, plus **`InvulnerabilitySystem`**, which removes the component rather than leaving
 it at zero so the query is empty in the common case.

- **`ArchCombat`** — deals damage *to* entities through the game's own rules. `Damage(entity, …)` for
 one, `DamageOverlapping(space, query, …)` for a melee swing. With no router assigned every hit is
 refused rather than dealt unfiltered: a swarm that cannot be hurt is noticed in seconds, a swarm that
 ignores friendly fire ships.

- **`PhysicsProxyRegistry`** and the `PhysicsProxy` component. Entities register a shape with
 `PhysicsServer2D` directly, so the player's existing `Area2D` finds them with no node involved.

 This is the one genuinely dangerous thing in the adapter and it is worth reading the type's remarks
 before using it. **Arch has no destructor hook** — nothing runs when an entity is destroyed — so a
 `World.Destroy` called outside the destruction queue leaks an area into the physics server forever,
 and the symptom is a frame rate that decays over a session while everything visible looks correct.
 Five things stand between a game and that, of which `Reconcile` is the one that does not depend on
 anyone having remembered anything. Compare `LiveCount` against the entity count if you suspect one.

- **`ArchDestructionQueue`** — one place entities die, drained by the world node after every stage.
 Closes the proxy leak by construction, since Arch offers nothing else the chance.

- **`ArchHitBuffer`** and `SwarmHit` — what happened to entities this frame, read once as a
 `ReadOnlySpan` instead of told two hundred times. A Godot signal per hit marshals every argument
 through a Variant; batching is also what makes the presentation sane, since two hundred floating
 damage numbers in one frame is unreadable at any cost.

- **`ArchWorldNode.Combat`, `.Destroyed`, `.Proxies` and `.Hits`**, with destruction drained after
 damage each stage and proxies reconciled on the existing prune interval.

- **A build-time Godot floor check** in the Arch package. `GodotSharp` is deliberately absent from the
 nuspec — declaring it makes NuGet unify against the SDK-supplied one and quietly demands a specific
 engine — which meant an engine below 4.4 failed at runtime with a `MissingMethodException` after a
 clean restore and compile. Error `NLC0001` now says so at build time instead.

- **Tests**: `DamageableViewTests` (the view and the model must resolve identically), `FactionIdTests`,
 `ArchDestructionQueueTests`, `SwarmCombatTests`, `SpatialHashGrid2DTests`, `SteeringTests`, `AttributeSetTests`, `HealthMaxValueTests`, `SwarmAnimationTests`. 1006 passing headless, plus the engine suite above.

### Added — after the first slice

The vertical slice proved the seam; these are the pieces that make a swarm something a player can see
and fight, plus the attribute work that has a consumer today.

- **`PhysicsProxySyncSystem2D`.** Moves each entity's shape to where the entity now is. Without it a
 swarm is hittable exactly once, in the spot it spawned — and it is the hardest absence here to
 diagnose, because the units are visibly walking around and the sword visibly passes through them.

- **`MultiMeshRenderSystem2D`** and the `Rendered` tag. Two thousand instances of one `MultiMesh` are
 one draw call, where two thousand `Sprite2D` nodes are two thousand canvas items. Everything else in
 the adapter saves CPU on simulation and then hands it all back at the point of drawing unless the
 drawing is batched too.

- **`DamageableReactiveComponent.GrantInvulnerability(float)`** and the matching
 `DamageableNode.GrantInvulnerability`. Invulnerability had exactly one way in — being hit — which
 covers the common case and none of the deliberate ones: a dash that passes through enemies, a parry
 window, the moment after a respawn. It **extends rather than replaces**, so a shorter grant can never
 cut short the frames a hit already gave you, and the node calls `UpdateProcessing` so the countdown
 actually runs. Not modelled as a `DamageOperation`, because every kind there resolves to a change in
 the pool and this changes none; the same shape as `HealthReactiveComponent.SetMaxValue`.

- **`ArchCombat.Options`.** The entity path hard-coded `DamageableOptions.Default`, so a game resolving
 criticals attacker-side — authoring `CriticalMultiplier = 1` on its resources, as the guide
 recommends — got them scaled a second time on entities and not on nodes. A crit on a swarm unit hit
 for exactly twice what the same swing did to a boss, which reads as a swarm bug rather than as two
 sets of rules. A property rather than a component because a critical multiplier and a resistance cap
 are the game's rules, not facts about one unit, unlike `Faction` and `Mitigation`.

- **`Animated.Flipped`**, written into `INSTANCE_CUSTOM.g` and read by `swarm_atlas.gdshader` as a
 horizontal mirror. On `Animated` rather than in a `Facing` component of its own, because the draw pass
 already reads `Animated` — a separate component would cost a hash lookup per entity per frame to ask
 whether it existed, which is the same trade `Rendered` refuses for tint and rotation. It exists
 because a sprite sheet draws a character facing one way and a swarm walks in every direction, so
 mirroring saves an artist three more rows per animation.

 Instance slots are deliberately **not** stable — an entity is written to whatever index it reaches
 during the walk — so nothing may be cached per slot across frames.

 Position only. Per-entity rotation, scale and tint were written and then removed: each is optional,
 so the renderer had to ask every entity whether it had them, and asking is a hash lookup per entity
 per frame. The way to add them back is a second query over the archetype that has the component, not
 a test inside the loop.

- **`SpatialHashGrid2D`**, **`SeparationSystem2D`**, **`SteeringSystem2D`**, **`SeekTargetSystem2D`**,
 and the `Seek` / `Separation` / `SeparationForce` components. Seek plus separation is the smallest
 pair that produces a crowd a player can fight: seek alone stacks every unit on the same pixel, and
 separation alone is a cloud that never arrives.

 Not full boids. Alignment and cohesion make a flock read as birds, which is the wrong feeling for
 something hunting you — aligned units drift as a mass and appear to be avoiding the player.

 **This departs from the plan, which said neighbours would come from the physics server.** Building it
 showed the cost is not what either of us assumed: a shape query per unit per frame returns an array of
 dictionaries from the C# binding, so two thousand units is two thousand allocations a frame, and
 monitor callbacks would mean two thousand managed `Callable`s invoked from native code. A grid over
 `Position2D` is chunk-linear and makes no native call at all. The server remains right for a *sword*
 hitting a swarm — a handful of queries a second — which is what `ArchCombat.DamageOverlapping` uses.
 Frequency is what separates the two cases. `SeparationForce` is the seam, so swapping the neighbour
 source back is one system.

- **`AttributeSet`**, `AttributeSetSnapshot`, `SaveParticipants.ForAttributeSet`, and the addon's
 **`AttributeSetResource`** / **`AttributeSetNode`**. Several named stats in one node, the same answer
 `CooldownSetNode` gives for eight abilities.

 Every entry is a real `AttributeReactiveComponent`, so gear, buffs and status effects still stack by
 the rules the attribute evaluator already tests. What makes a hand-rolled dictionary of floats look
 attractive is only ever the node count, and what it costs is every one of those rules — including
 being reachable by `StatusEffectSystem` at all.

 One save id covers the set. An unknown saved key is warned about and skipped; a stat the save does not
 mention keeps its configured value. Bounds are not captured, so a rebalanced cap reaches old saves.

- **`HealthReactiveComponent.SetMaxValue(max, value?)`.** There was no public way to move a ceiling at
 runtime — only `RestoreState`, which is the load path. Using that for a live change works and reads as
 a mistake, which is worse than either. Raises `OnModelSet` once, raises no domain event: a ceiling
 dropping to zero is not a death, because nothing dealt any damage.

- **`ISwarmAnimationSet`** and `SwarmClip` in the glue, **`SwarmAnimationResource`** /
 `SwarmClipResource` / `swarm_atlas.gdshader` in the addon, **`Animated`** and
 **`SwarmAnimationSystem`** in Arch. Animating a swarm that has no nodes to hang an
 `AnimatedSprite2D` on.

 A `MultiMesh` is one mesh, one material and one texture, so per-unit frame selection cannot swap
 textures and has to happen in a shader reading a frame index from per-instance custom data. The trade
 is stated plainly in the resource: `AnimationDirector2D` does not serve swarm units, and neither does
 anything built on `SpriteFrames`. What you get back is two thousand animated units in one draw call.

 Frame selection is folded into `MultiMeshRenderSystem2D` rather than given its own system, because
 instance slots are assigned by walk order — two systems writing transforms and frames separately would
 disagree the moment one entity gained or lost the component, and the symptom is units wearing each
 other's animations.

 Clips are addressed by index, not name. A string in a component makes it managed, which takes every
 entity carrying it off the fast path; resolve names once at spawn with `IndexOf`.

- **`tests/Noodloft.Components.EngineTests`** and `.gitea/workflows/engine-tests.yml`. The half of the
 suite that cannot run headless: physics RIDs the server actually hands out and takes back, real
 `MultiMesh` buffers, signal emission, and the node lifecycle around `NotificationPredelete` and
 reparenting — including the first check that reparenting does **not** destroy a component, which has
 been a claim in a comment since the first release.

 Gated to pull requests into `master` and to tags, not every push. `build.yml` runs 1006 xUnit tests in
 seconds with no download; fetching an engine on every commit is a bad trade.

 Pinned to Godot 4.7.1, which is above the 4.4 floor the libraries target — GoDotTest is versioned in
 step with the engine and its current release requires 4.7.1. Testing above the floor is the right way
 round, since the referenced assemblies compiled against 4.4 bindings and a higher runtime satisfies
 them. What is genuinely not covered is a 4.4-only regression, and that is a gap rather than an
 omission.

- **`AttributeHealthLink`** and `MaxHealthChangePolicy`. Pushes a stat into a `DamageableNode`'s
 maximum. The direction is the design — the damageable never reads attributes, which is what keeps
 damage and progression independent.

 `KeepAbsolute` is the default and the only rule immune to equipping and unequipping the same item as a
 free heal. `KeepRatio` is what a shrinking pool usually wants; `Refill` is a reward and should never
 be attached to gear.

### Fixed

- **`swarm_atlas.gdshader` no longer draws every unit upside down.** A `QuadMesh` is a 3D primitive and
 its UVs are laid out for a world where +Y is up; a canvas has +Y down. Drawing one through a
 `MultiMeshInstance2D` — which is what `MultiMeshRenderSystem2D` documents as the setup — therefore
 flipped every frame vertically. Corrected in the shader, where the mesh being used is known, rather
 than by asking artists to flip their sheets; `flip_v` turns it off for a game supplying its own mesh
 with canvas-correct UVs. Found the first time a swarm was actually put on screen.

- **`SelfDamageFilter` no longer lets an actor hit itself.** It resolved both nodes to their `Owner`
 and compared those, which never matched for the shape every game actually has: a game names the body
 it swung from as the source, and the target is always the `DamageableNode` *underneath* that body.
 The root of an instanced scene is owned by the scene that instanced it, while the nodes inside it are
 owned by the root — so the attacker resolved to the level and the victim resolved to the actor, one
 level apart, and a rule whose entire job is refusing self-damage refused nothing.

 It now treats two nodes as one actor when either contains the other, or when both resolve to the same
 instance — with an instance root resolving to itself, and the level never counting as an actor so a
 spike trap can still hurt a barrel authored beside it. Found in a real game, which is the only place
 it could have been found: the shape that breaks it cannot occur in a hand-built tree.

- **`FactionNode.Of` now looks beside the node as well as below and above it.** It searched children
 then ancestors, and the caller that matters most is `DamageableNode.ToView` asking which side its own
 entity is on — where the faction is a *sibling*, since both hang off the body. So every target
 reported as unaligned, and `FactionDamageFilter` silently allowed every hit including friendly fire
 and self-damage. `FindSiblingComponent` existed for exactly this case and its own documentation names
 it; the lookup just did not call it.

- **Every addon resource now derives from `global::Godot.Resource`.** They said `Godot.Resource`, which
 binds to whatever `Godot` is reachable from `Noodloft.Components.Addon.Resources` — and in a consuming
 assembly that happens to define a `Noodloft.Components.Godot` namespace, that is the wrong one. The
 failure is not a missing type: it is `GD0102` on every single `[Export]` of a resource, which reads as
 the addon being fundamentally broken rather than as one name resolving oddly. Found by building the
 new engine-test project, which was briefly named exactly that.

- **The Arch test assembly no longer runs its classes in parallel.** Arch keeps worlds in a static
 table and recycles the slots `World.Destroy` frees, so two classes creating and destroying worlds on
 different threads contend over it — and it fails as an entity reported dead because another class
 recycled its world's slot, not as an exception. It surfaced the moment a third and fourth test class
 were added, which is how long this kind of bug stays hidden.

### Deprecated

- **`ProjectileSystem2D` is now documented as a reference implementation, not a shipped system.** No
 game in this repository fires a projectile, so unlike everything beside it, it has never run a frame
 in anger. The swept-ray logic is the non-obvious part and worth keeping written down; treat the rest
 as a starting point to copy rather than something to depend on.

### Notes

- **Arch is now referenced as `[2.1.0,3.0.0)`** rather than pinned. The family around it —
 `Arch.System`, `Arch.Relationships`, `Arch.Persistence` — is versioned in step, and an exact pin puts
 anyone who wants one of those into a unification fight over a package this library has no opinion
 about beyond "2.x".

- **Swarm entities are not saved**, deliberately, for the same reason `AnimationDirector2D` has
 `CanPersist => false`. Physics RIDs, render slots and steering state are all derived and rebuildable
 from a spawn count and a position list. A unit that must survive a save has stopped being swarm and
 wants to be a node.