Tech Tree API

Events

The three events, how often each one fires, and why the recommended way to apply an effect is a getter postfix and not an event at all.

The three events

EventSignatureFires
LevelChangedAction<string,int> Right after any level is bought or sold back, once. Includes Tech Tree's own nodes.
SaveLoadedAction<int> Once per load, after the levels for that slot have been read. The argument is the slot index.
ApplyAction About every two seconds, after Tech Tree applies its own effects.

Per-node versions of the first and third exist on the definition itself - OnLevelChanged and OnApply - and are usually what you want, since they save you filtering by id.

csharp
TechTreeApi.LevelChanged += (id, level) =>
{
    if (id == "bigboxes.capacity") Log.LogInfo($"capacity -> {level}");
};

TechTreeApi.SaveLoaded += slot => Log.LogInfo($"slot {slot} loaded");
TechTreeApi.Apply      += () => { /* about every two seconds */ };

Applying an effect: pick the right shape

How you apply an upgrade depends entirely on how the game reads the value.

The game...Use
calls a property or method every time it needs the value Harmony postfix on the getter. Best option by far.
reads a field once, on spawn or on enable Apply / OnApply, writing the field.
rewrites the field itself every frame Your own MonoBehaviour.Update. Apply is too slow.
uses the value once when something happens A patch on that thing, reading GetLevel at the moment it happens.

The recommended pattern: patch the getter

This is what most of Tech Tree's own upgrades do. There is no instance lookup, no timing question, no state to keep in sync - the game asks, you answer.

csharp
[HarmonyPatch(typeof(SomeGameClass), "get_SomeInterval")]
internal static class SomeIntervalPatch
{
    private static void Postfix(ref float __result)
    {
        int lvl = TechTreeApi.GetLevel("bigboxes.speed");
        if (lvl <= 0) return;
        __result *= 1f - 0.05f * lvl;      // lower interval = faster
    }
}
Why the getter and not the field

Several values in this game are computed properties, and several others are written back by the game on its own schedule. Setting the field looks like it works and then quietly stops - the game puts its own number back on the next tick. Patching the getter hands your number to the game through its own code path, so it sticks, and anything downstream (animation speed, for instance) follows on its own.

When there is no getter: write the field on Apply

csharp
new UpgradeDefinition
{
    Id = "bigboxes.capacity",
    // ...
    OnApply = () =>
    {
        int lvl = TechTreeApi.GetLevel("bigboxes.capacity");
        if (lvl <= 0) return;

        foreach (var thing in Resources.FindObjectsOfTypeAll<SomeThing>())
        {
            if (thing == null || !thing.gameObject.scene.IsValid()) continue;
            thing.Capacity = BaseFor(thing) + lvl * 2;   // absolute, never +=
        }
    },
}
Never accumulate

Apply runs again and again. thing.Capacity += 2 makes the value climb forever; within a minute the player has a box that holds four thousand items. Always compute from a remembered base value, so running it a hundred times gives the same answer as running it once.

Remembering the base value

Memoise the first value you ever saw for each object, keyed by instance id:

csharp
private static readonly Dictionary<int, float> Base = new();

private static float BaseOf(UnityEngine.Object o, float current)
{
    int key = o.GetInstanceID();
    if (!Base.TryGetValue(key, out var v)) { v = current; Base[key] = v; }
    return v;
}

// Reset when a save loads: pooled objects reuse instance ids.
TechTreeApi.SaveLoaded += _ => Base.Clear();
Never capture a zero as the base

A stopped NPC reads speed 0. If that zero becomes the remembered base, that object is multiplied by zero forever and never moves again. Skip anything at or near zero and pick it up on the next pass - this exact bug froze every customer in the store during development.

What Apply is not

Apply fires roughly every two seconds. It is a re-assert pass for things that spawn while the player is playing - a newly hired employee, a customer walking in - not a game loop.

FrequencyReach for
Every frameYour own injected MonoBehaviour with an Update
Every ~2 sApply / OnApply
On change onlyLevelChanged / OnLevelChanged
On demandA getter postfix reading GetLevel

Order and isolation

Subscribers run after Tech Tree's own six effect passes, each inside its own try/catch. A handler that throws is logged with your node id and does not stop the other subscribers or the built-in upgrades. Repeated identical failures are silenced after five lines so one broken mod cannot bury the log.

That protection is not a licence to be slow: everything runs on the main thread, so a handler that takes 50 ms is a visible stutter every two seconds.

Order at load

When a save loads, the sequence is:

  1. levels are read from the slot file
  2. SaveLoaded(slot)
  3. the first Apply of that session

So SaveLoaded is the right place to clear caches - GetLevel is already correct by then, and your reset lands before the first apply rather than after it.