Refreshing the Feelings Unity Asset

If you’ve been using Feelings in your Unity project – or you’ve been eyeing it on the Asset Store for that one NPC who needs more than a bool isAngry – the latest update is a big one. Over the past stretch I’ve reworked the asset from the inside out: a real Editor experience, hardened persistence, a saner runtime, proper UPM packaging, and documentation that no longer trails the code by three years.

Here’s what’s new and why it matters.

A real Editor experience

This is where the update shines. Until now, authoring a feelings graph meant subclassing FeelingsMap and writing a bunch of SetEffect("Happy", "Sad", -1f) lines. It worked, but you couldn’t see the graph, you couldn’t reuse it without a recompile, and a typo in a feeling name was a runtime mystery.

That’s gone:

  • FeelingsMapDefinition – a ScriptableObject you author from the Inspector. Two compact tables (Feelings and Effects), no code required. Construct a runtime map with new FeelingsMap(definition).
  • Live graph view at the bottom of the inspector. Nodes laid out on a circle, edges color-coded by sign (green = positive cascade, red = opposing), width and opacity scaled by magnitude, ratio labels on each edge. Self-effects render as a small ↻ 0.5 badge on the node so the graph stays readable.
  • Source/Target dropdowns that auto-populate from the feelings you’ve already declared in the asset. No more "Hapyy" typos shipping to production.
  • Range slider for Ratio, restored after Unity quietly downgraded [Range] to a numeric field at narrow column widths.
  • Custom inspector for FeelingsService showing each registered serializer with an availability dot, a Set Default button per row, and a Quick Actions panel for save/load/delete.
  • Tools > Feelings menu – open the persistence folder, reset the service between scene reloads, log serializer info, jump to the docs.

The data-driven path doesn’t replace subclassing – sometimes a const string and a constructor is exactly the right shape. But for designers who want to tune emotion balance without bothering an engineer, the inspector is finally a first-class workflow.

Hardened persistence

The persistence layer was the part of the asset I was most embarrassed by. Seven serializers, all working, but with a few sharp edges that a determined player could wedge a save-editor or a malformed JSON into.

  • Path sandboxing. Every file-based serializer now resolves location against Application.persistentDataPath through FeelingsPathSandbox. Absolute paths and .. traversal get refused with a logged error. If you used to pass Path.Combine(Application.persistentDataPath, "save.json"), you can now just pass "save.json".
  • Opt-in HMAC integrity check. FeelingsIntegrity.Enabled = true plus a project-specific secret stamps every save with an HMAC-SHA256 signature; loads that fail verification return null instead of poisoning the runtime state. Single-player projects that don’t care can leave it off.
  • BinarySerializer marked [Obsolete]. BinaryFormatter is a long-standing RCE risk; the obsolete attribute steers people toward Newtonsoft or the binary-via-Odin path without breaking existing code.
  • RestoreFromSnapshot validates everything – rejects NaN/Infinity, clamps into [-100, 100]. Hand-edited save files can no longer set Hate = 1e30.
  • PlayerPrefs no longer leaks orphan keys. The serializer now writes a single JSON blob under a fpfx_<hash> prefix and tracks counts so shrinking a save cleans up after itself.
  • CloudSaveSerializer doesn’t deadlock the editor – there’s a new IAsyncFeelingsSerializer interface and async extension methods, so synchronous task.Wait() is gone from the hot path.

The thread-safety story is also clearer now. FeelingsMap is locked internally and safe from any thread; the persistence layer is documented as main-thread only (several serializers touch Unity APIs that aren’t thread-safe), and the async interface is the supported way to do background saves.

Bug fixes worth calling out

A few long-standing issues finally got squashed:

  • Cascade now propagates the effective (post-clamp) delta, not the requested one. If Mad was already at +95 and you applied another +50, downstream feelings used to receive 50 * ratio; now they receive 5 * ratio, which is what every user assumed all along.
  • Cycle protection marks at push, not pop. The visited-set guard was clearing too late, so the first encounter of a feeling in a deep cascade could double-count. Fixed and covered by a regression test.
  • FeelingChanged subscribers are isolated. A handler that throws no longer prevents later subscribers from firing. Implementation: walk GetInvocationList() and try/catch each handler individually.
  • ApplyFeelingInternal is now iterative DFS with a pooled stack, so a 1000-node cascade no longer risks a real StackOverflowException.
  • FeelingsService.OnDestroy releases its persistence-manager reference, so EditMode tests that build and tear down the service in the same frame don’t leak.
  • Renamed misspelled public types (FeelingsPeristenceManagerFeelingsPersistenceManager, etc.). The old names stay as [Obsolete] aliases so nothing breaks today; they’ll be removed in a future major.

API additions

A handful of small things that turned out to make a big difference:

// React to anything that changed, without polling.
map.FeelingChanged += (name, value) => Debug.Log($"{name} -> {value:0.0}");

// No-cascade helpers for save-restore, debug menus, cheat codes.
map.SetFeeling(BasicFeelingsMap.Mad, 0f);
map.ResetFeeling(BasicFeelingsMap.Mad);
map.ResetAll();

// Introspection for editor tools and tests.
foreach (var (target, ratio) in map.GetEffects(BasicFeelingsMap.Mad))
    Debug.Log($"Mad -> {target} ({ratio})");

// Allocation-free name dump for hot UI loops.
var buffer = new List<string>();
map.GetAllFeelingNames(buffer);

Plus a FeelingsPersistenceManager.LastSuccessfulSerializer property so you can tell which fallback caught the load, and static convenience overloads (ExistsStatic, DeleteStatic, SetDefaultSerializerStatic) that don’t require dragging the singleton through every call site.

Faster, quieter, lighter

A few hot-path cleanups:

  • Effect storage moved to Dictionary<string, float> – faster lookups, smaller working set than the previous nested dictionary.
  • Cascade scratch buffers (HashSet, Stack, change list) are pooled per FeelingsMap instance instead of allocated per call. ApplyFeeling is now allocation-free in steady state.
  • CreateSnapshot sizes its arrays exactly and drops LINQ. No more allocator pressure when you autosave every few seconds.
  • Verbose registration logs are elided from non-editor builds behind a [Conditional("UNITY_EDITOR")] wrapper.

Documentation that matches the code

The old Documentation.md was a bit of a museum piece – it referenced a FeelingsMap.Save(filePath) API that hasn’t existed in years and was missing the persistence sandbox, the integrity check, the editor inspector, and the FeelingChanged event entirely. The new docs include:

  • A rewritten Documentation.md that actually matches the API.
  • Class-level XML docs on every predefined map showing the full effect graph as an ASCII table, plus a worked-example cascade walkthrough on FeelingsMap itself.

What does the workflow look like now?

using Feelings;

public class Merchant : MonoBehaviour
{
    public FeelingsMapDefinition definition;   // authored in the Inspector
    private FeelingsMap _feelings;

    void Awake()
    {
        _feelings = new FeelingsMap(definition);
        _feelings.FeelingChanged += (n, v) => UpdateMoodIcon(n, v);
    }

    public void OnPlayerHaggled()    => _feelings.ApplyFeeling("Mad", 10);
    public void OnPlayerCompliment() => _feelings.ApplyFeeling("Joyful", 5);

    public float PriceMultiplier()
    {
        var mood = _feelings.GetFeeling("Joyful") - _feelings.GetFeeling("Mad");
        return 1f - mood / 200f;
    }

    void OnDisable() =>
        FeelingsService.Instance.Save(_feelings, $"merchants/{name}");
}

Or stay in the Editor entirely: create a FeelingsMapDefinition from Assets > Create > Feelings > Feelings Map Definition, fill in the two tables, watch the graph rearrange itself live, and never touch the constructor.

Et-voila!