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
| Event | Signature | Fires |
|---|---|---|
LevelChanged | Action<string,int> |
Right after any level is bought or sold back, once. Includes Tech Tree's own nodes. |
SaveLoaded | Action<int> |
Once per load, after the levels for that slot have been read. The argument is the slot index. |
Apply | Action |
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.
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.
[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
}
}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
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 +=
}
},
}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:
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();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.
| Frequency | Reach for |
|---|---|
| Every frame | Your own injected MonoBehaviour with an Update |
| Every ~2 s | Apply / OnApply |
| On change only | LevelChanged / OnLevelChanged |
| On demand | A 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:
- levels are read from the slot file
SaveLoaded(slot)- the first
Applyof 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.