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– aScriptableObjectyou author from the Inspector. Two compact tables (Feelings and Effects), no code required. Construct a runtime map withnew 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.5badge 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
FeelingsServiceshowing each registered serializer with an availability dot, a Set Default button per row, and a Quick Actions panel for save/load/delete. Tools > Feelingsmenu – 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
locationagainstApplication.persistentDataPaththroughFeelingsPathSandbox. Absolute paths and..traversal get refused with a logged error. If you used to passPath.Combine(Application.persistentDataPath, "save.json"), you can now just pass"save.json". - Opt-in HMAC integrity check.
FeelingsIntegrity.Enabled = trueplus a project-specific secret stamps every save with an HMAC-SHA256 signature; loads that fail verification returnnullinstead of poisoning the runtime state. Single-player projects that don’t care can leave it off. BinarySerializermarked[Obsolete].BinaryFormatteris a long-standing RCE risk; the obsolete attribute steers people toward Newtonsoft or the binary-via-Odin path without breaking existing code.RestoreFromSnapshotvalidates everything – rejects NaN/Infinity, clamps into[-100, 100]. Hand-edited save files can no longer setHate = 1e30.PlayerPrefsno longer leaks orphan keys. The serializer now writes a single JSON blob under afpfx_<hash>prefix and tracks counts so shrinking a save cleans up after itself.CloudSaveSerializerdoesn’t deadlock the editor – there’s a newIAsyncFeelingsSerializerinterface and async extension methods, so synchronoustask.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
Madwas already at +95 and you applied another +50, downstream feelings used to receive50 * ratio; now they receive5 * 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.
FeelingChangedsubscribers are isolated. A handler that throws no longer prevents later subscribers from firing. Implementation: walkGetInvocationList()and try/catch each handler individually.ApplyFeelingInternalis now iterative DFS with a pooled stack, so a 1000-node cascade no longer risks a realStackOverflowException.FeelingsService.OnDestroyreleases 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 (
FeelingsPeristenceManager→FeelingsPersistenceManager, 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 perFeelingsMapinstance instead of allocated per call.ApplyFeelingis now allocation-free in steady state. CreateSnapshotsizes 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
FeelingsMapitself.
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!