BrunoCPF.Modifiable
0.3.2
dotnet add package BrunoCPF.Modifiable --version 0.3.2
NuGet\Install-Package BrunoCPF.Modifiable -Version 0.3.2
<PackageReference Include="BrunoCPF.Modifiable" Version="0.3.2" />
<PackageVersion Include="BrunoCPF.Modifiable" Version="0.3.2" />
<PackageReference Include="BrunoCPF.Modifiable" />
paket add BrunoCPF.Modifiable --version 0.3.2
#r "nuget: BrunoCPF.Modifiable, 0.3.2"
#:package BrunoCPF.Modifiable@0.3.2
#addin nuget:?package=BrunoCPF.Modifiable&version=0.3.2
#tool nuget:?package=BrunoCPF.Modifiable&version=0.3.2
com.brunocpf.modifiable-property
A reactive, extensible stat & value-transformation pipeline for C#, Unity, and Godot (powered by R3)
<p align="center"> <img src="https://img.shields.io/badge/Unity-2021%2B-black?logo=unity" /> <img src="https://img.shields.io/badge/Godot-4%20(.NET)-478cbf?logo=godotengine&logoColor=white" /> <img src="https://img.shields.io/badge/.NET-netstandard2.1-512bd4?logo=dotnet&logoColor=white" /> <img src="https://img.shields.io/nuget/v/BrunoCPF.Modifiable?logo=nuget&label=NuGet" /> <img src="https://img.shields.io/badge/R3-Compatible-blue" /> <img src="https://img.shields.io/badge/License-MIT-green" /> <br> <img src="https://img.shields.io/github/stars/brunocpf/modifiable-property?style=social" /> </p>
What Is This?
ModifiableProperty is a powerful, reactive wrapper around a value that supports:
- Deltas — incremental changes (damage, healing, EXP gain, gold gain)
- Filters — change-time transformations (EXP boosts, healing block, damage reduction)
- Bounds — min/max constraints (HP ≥ 0, HP ≤ MaxHP)
- Modifiers — view-time transformations (equipment, buffs, multipliers)
- Disposable Push/Pop Effects — perfect for RPG buffs, items, equipment, temporary states
- Context-aware changes — pass structured metadata with each delta
- Reactive observation — subscribe to base, effective, or delta streams
- Custom numeric support — define your own math for custom structs
It is ideal for:
- RPG stats
- Character attributes
- Currency systems
- Simulation values
- Buff & debuff systems
- Combat logic
- Ability and item effects
Installation
The library code is engine-agnostic (no UnityEngine / Godot references) and lives in
one place — unity/Runtime. Unity compiles it via the UPM .asmdef; the SDK project in src/
compiles the same files for Godot / plain .NET / NuGet. Edit once, both engines build it.
Unity (UPM)
Add via Package Manager → Add package from git URL:
https://github.com/brunocpf/modifiable-property.git?path=unity
Or in Packages/manifest.json:
{
"dependencies": {
"com.brunocpf.modifiable-property": "https://github.com/brunocpf/modifiable-property.git?path=unity"
}
}
The package lives in the
unity/subfolder, hence the?path=unitysuffix.
R3 prerequisite (Unity): install R3 core via NuGetForUnity
(the R3 NuGet package) plus the R3.Unity package for the engine
integration, before adding this package.
Godot 4 (.NET) / plain .NET
The library targets netstandard2.1, which Godot 4's .NET runtime and any modern .NET app
consume directly; R3 flows in transitively.
Install from NuGet.org:
dotnet add package BrunoCPF.Modifiable
Or reference the source project (for local iteration / debugging into the library):
<ItemGroup>
<ProjectReference Include="path/to/modifiable-property/src/BrunoCPF.Modifiable/BrunoCPF.Modifiable.csproj" />
</ItemGroup>
Outside Unity, never reference
R3.Unity— the plainR3NuGet package (pulled in automatically by the library) is the one you want.
Core Concept
A ModifiableProperty processes changes through five ordered layers:
Raw Deltas
↓
Filters (change-time logic)
↓
Bounds (min/max)
↓
Base Value (persistent)
↓
Modifiers (view-time logic)
↓
Effective Value (final)
Each layer solves a different category of gameplay logic:
| Layer | Purpose | Examples |
|---|---|---|
| Deltas | Raw changes | -10 HP, +100 EXP, +1 Level |
| Filters | Modify or reject deltas | EXP boost, block healing, clamp HP by Max HP |
| Bounds | Enforce range | HP ≥ 0, ATK ≤ 999 |
| Base Value | Accumulated state | Real stored HP/EXP/etc |
| Modifiers | View-time effects | Equipment, buffs, debuffs |
Quick Example
var hp = new ModifiableProperty<int, object>(
initialValue: 100,
min: 0,
max: 100
);
// Damage
hp.AddDelta(-30); // → 70
// Healing
hp.AddDelta(+20); // → 90
// Equipment (modifier)
var sword = hp.PushModifier(
id: "sword",
modifyFunc: v => v + 10
);
Debug.Log(hp.CurrentValue); // → 100
sword.Dispose(); // remove equipment
API Overview
public sealed class ModifiableProperty<TValue, TContext>
✔ Add deltas
AddDelta(delta)
AddDelta(ValueDelta<TValue, TContext>)
✔ Add / remove temporary effects
PushModifier()
PushFilter()
✔ Set base value
SetBaseValue(newValue)
✔ Observe values
property.Subscribe(...)property.Base.Subscribe(...)property.ProcessedDeltas.Subscribe(...)
✔ Encapsulation
public interface IReadOnlyModifiableProperty<TValue, TContext>
- Read-only access to ModifiableProperty (exposes value, base and delta streams, but not mutation methods).
Use:
private ModifiableProperty<int, object> hp = new(...);
public IReadOnlyModifiableProperty<int, object> HP => hp;
Filters (Change-Time Logic)
Filters modify deltas before they affect the base value.
EXP Boost
exp.PushFilter("boost", d => d with { Delta = (int)(d.Delta * 1.5f) });
Block Healing
hp.PushFilter(
"no-healing",
d => d.Delta > 0 ? d with { Delta = 0 } : d
);
Minimum Damage Rule (“1 damage minimum”)
hp.PushFilter(
"min-dmg",
d => d.Delta < 0 && d.Delta > -1 ? new(-1, d.Context) : d
);
Filters run in priority order (the third argument), lowest first.
Modifiers (View-Time Logic)
Modifiers affect the final value after the base is computed.
Equipment
atk.PushModifier("sword", v => v + 20);
Buff
atk.PushModifier("atk-up", v => (int)(v * 1.2f));
Debuff
atk.PushModifier("atk-down", v => (int)(v * 0.5f));
Modifiers also stack in priority order (third argument), lowest first.
In most cases, you want flat bonuses to have a lower priority than multipliers.
Bounds
Bounds apply after deltas are integrated, not to modifiers.
Example:
var hp = new ModifiableProperty<int, object>(50, min: 0, max: 100);
hp.AddDelta(-200); // → 0
hp.AddDelta(+999); // → 100
Use bounds to enforce static gameplay rules (e.g., HP can’t ever go below 0). For dynamic caps (e.g., HP ≤ MaxHP), use filters.
Delta Contexts
You can optionally provide metadata for filtering logic:
public record DamageContext(Battler Source) : IValueContext;
hp.AddDelta(-30, new DamageContext(attacker));
Filters and subscribers receive structured metadata and adjust their behavior accordingly.
hp.PushFilter("shield", d =>
{
if (d.Context is DamageContext dc && dc.Source.HasStatus("armor_break"))
{
return d; // no reduction
}
return d.Delta < 0 ? d with { Delta = d.Delta + 10 } : d;
});
Custom Math (Non-Numeric Types)
By default, ModifiableProperty provides arithmetic for int, long, float, and double.
If your type doesn’t support numeric operators, supply custom math:
public struct Mana { public int Value; }
public class ManaMath : IValueMath<Mana>
{
public Mana Add(Mana a, Mana b) => new() { Value = a.Value + b.Value };
public Mana Subtract(Mana a, Mana b) => new() { Value = a.Value - b.Value };
}
Use it like:
var mana = new ModifiableProperty<Mana, object>(
new Mana { Value = 10 },
valueMath: new ManaMath()
);
This is particularly useful for enums with custom logic (e.g., elemental affinities).
If you don't provide custom math and use a non-numeric type, you might get unexpected behavior.
Integration Patterns
Buffs
IDisposable buff = atk.PushModifier("buff", v => v + 10);
buff.Dispose(); // removes effect
Equipment
IDisposable equip = atk.PushModifier("sword", v => v + 25);
equip.Dispose(); // unequip
Blocking Healing
var block = hp.PushFilter("block-healing", d =>
d.Delta > 0 ? d with { Delta = 0 } : d
);
block.Dispose();
Observability
Effective value
hp.Subscribe(v => Debug.Log($"HP → {v}"));
Base value
hp.Base.Subscribe(v => Debug.Log($"Base HP → {v}"));
Processed deltas
Use this to trigger effects on changes. Useful for damage/healing reactions.
hp.ProcessedDeltas.Subscribe(d =>
Debug.Log($"Delta {d.Delta}, context={d.Context}")
);
Best Practices
✔ Use filters for change-time logic
(EXP boosts, reducing damage, blocking healing)
✔ Use modifiers for view-time logic
(Equipment, buffs, transformations)
✔ Give every effect a unique ID
Prevents duplicates and enables precise removal. You can use GUIDs (System.Guid.NewGuid().ToString()) for temporary effects.
✔ Use disposables for lifetime
Temporary effects naturally tie into gameplay duration. Use IDisposable references to manage lifetimes (e.g., buffs, equipment).
✔ Keep heavy logic outside subscriptions
Keep subscriptions observational, not mutative.
FAQ
Q: Can modifiers cause infinite loops?
No. Value flow is strictly one-directional:
deltas → base → modifiers → effective
Still, maintain discipline in filters/modifiers to avoid unintended side effects.
RPG Example
public interface ICtx { }
public record AttackCtx(Battler Source) : ICtx;
public record HealCtx(Battler Source) : ICtx;
public class Battler
{
public string Name { get; set; }
public readonly ModifiableProperty<int, ICtx> Hp;
public readonly ModifiableProperty<int, ICtx> Atk;
public Battler(string name)
{
Name = name;
Hp = new ModifiableProperty<int, ICtx>(100, min: 0, max: 100);
Atk = new ModifiableProperty<int, ICtx>(10, min: 1);
Hp.ProcessedDeltas.Subscribe(delta =>
{
if (delta.Delta < 0 && delta.Context is AttackCtx ctx)
{
Debug.Log($"{ctx.Source} dealt {-delta.Delta} damage to {this}!");
}
else if (delta.Delta > 0 && delta.Context is HealCtx ctx)
{
Debug.Log($"{ctx.Source} healed {this} for {delta.Delta} HP!");
}
});
}
public override string ToString() => Name;
}
var hero = new Battler("Hero");
var ally = new Battler("Ally");
var monster = new Battler("Monster");
// Hero attacks monster
int damage = hero.Atk.CurrentValue;
monster.Hp.AddDelta(-damage, new AttackCtx(hero));
// Ally heals hero
ally.Hp.AddDelta(+20, new HealCtx(ally));
Roadmap
- Unity Samples~/ package
- Visual debugging inspector
Repository Layout
.
├── src/BrunoCPF.Modifiable/ # SDK library (Godot / .NET / NuGet) — compiles the
│ # source under unity/Runtime; no duplicated code
├── tests/BrunoCPF.Modifiable.Tests/ # engine-free NUnit suite (dotnet test)
├── unity/ # UPM package (consumed via ?path=unity)
│ ├── package.json
│ ├── Runtime/ # the actual source — single source of truth
│ └── Samples~/ # Unity-only MonoBehaviour sample
└── BrunoCPF.Modifiable.slnx
The library code lives once, under unity/Runtime. Unity compiles it via the .asmdef;
the src/ project <Compile Include>s the same files for everyone else. Keep that code free
of both UnityEngine and Godot types — engine-specific glue belongs in unity/ overlays or
a future Godot addon, never in the shared source.
Building & Testing
No engine required — the tests validate the exact code both Unity and Godot consume.
dotnet build BrunoCPF.Modifiable.slnx -c Release # library + tests
dotnet test BrunoCPF.Modifiable.slnx -c Release # engine-free NUnit suite
License
MIT License © Bruno Fernandes Free for commercial and non-commercial use.
Contributing
PRs welcome!
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. 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 was computed. 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. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- R3 (>= 1.3.1)
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.3.2 | 134 | 7/23/2026 |
| 0.3.1 | 149 | 6/18/2026 |
| 0.3.0 | 139 | 6/17/2026 |
| 0.2.0 | 124 | 6/16/2026 |
| 0.2.0-rc.1 | 76 | 6/15/2026 |