BrunoCPF.Modifiable 0.3.2

dotnet add package BrunoCPF.Modifiable --version 0.3.2
                    
NuGet\Install-Package BrunoCPF.Modifiable -Version 0.3.2
                    
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="BrunoCPF.Modifiable" Version="0.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="BrunoCPF.Modifiable" Version="0.3.2" />
                    
Directory.Packages.props
<PackageReference Include="BrunoCPF.Modifiable" />
                    
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 BrunoCPF.Modifiable --version 0.3.2
                    
#r "nuget: BrunoCPF.Modifiable, 0.3.2"
                    
#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 BrunoCPF.Modifiable@0.3.2
                    
#: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=BrunoCPF.Modifiable&version=0.3.2
                    
Install as a Cake Addin
#tool nuget:?package=BrunoCPF.Modifiable&version=0.3.2
                    
Install as a Cake Tool

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=unity suffix.

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