Tech Tree API
Full example
A complete mod with two nodes, a built-in icon, a custom PNG, a gate and a real effect. This is the code that ships in the Tech Tree repo.
What it does
| Node | Levels | Effect | Shows off |
|---|---|---|---|
ttexample.bulk_discount | 3 | 5% cashback on supply costs per level | Built-in icon, default cost curve |
ttexample.loyal_shoppers | 2 | +3% on every sale per level | Own PNG, custom gate, OnLevelChanged |
The second node is locked until the first one is bought - using nothing but the public API, so it needs no knowledge of the game's internals.
| File | What it is for |
|---|---|
Plugin.cs | Talks to the API: declares the two nodes |
Effects.cs | Makes the levels mean something. The actual mod |
CashbackScheduler.cs | Pays the refund a beat later, so the game's money animation does not swallow it |
IconData.cs | The PNG for the second icon, as base64 |
Plugin.cs
using BepInEx;
using BepInEx.Logging;
using BepInEx.Unity.IL2CPP;
using HarmonyLib;
using Il2CppInterop.Runtime.Injection;
using TechTree.Api;
namespace ExampleTechTreeMod;
[BepInPlugin(Guid, "Example Tech Tree Mod", "1.0.0")]
[BepInDependency(TechTreeApi.ModGuid)]
public sealed class Plugin : BasePlugin
{
public const string Guid = "ExampleTechTreeMod";
// Ids are the key in the save file: never change them after release.
public const string BulkDiscount = "ttexample.bulk_discount";
public const string LoyalShoppers = "ttexample.loyal_shoppers";
// One source for the effect strength: the text the player reads and the code
// that pays out both come from here, so they cannot drift apart.
public const float DiscountPerLevel = 0.05f;
public const float BonusPerLevel = 0.03f;
internal static new ManualLogSource Log;
public override void Load()
{
Log = base.Log;
// One call, once. "Example" is the label of your sub-tab inside the Mods
// tab, and it is the only thing you ever say about where your nodes go.
var tree = TechTreeApi.ForPlugin(Guid, "Example");
// MaxLevel 3 means Tech Tree draws three chained circles, not one, and the
// player buys them top to bottom. 1200 x 1.8 prices them 1200 / 2160 / 3888.
var bulk = tree.Register(new UpgradeDefinition
{
Id = BulkDiscount,
DisplayName = "Bulk Discount",
MaxLevel = 3,
BaseCost = 1200,
CostMultiplier = 1.8f,
SortOrder = 0,
Icon = UpgradeIcon.Builtin("truck"),
// Called with the level, every time the hover card is drawn.
DescribeLevel = lvl => $"{lvl * DiscountPerLevel * 100:0}% back on supply costs",
});
// Register never throws - it runs inside YOUR Load, and an exception here
// would abort YOUR plugin over a typo. Checking the result is the only way
// to find out something went wrong.
if (!bulk.Success) Log.LogError($"Bulk Discount refused: {bulk.Result}");
var loyal = tree.Register(new UpgradeDefinition
{
Id = LoyalShoppers,
DisplayName = "Loyal Shoppers",
MaxLevel = 2,
BaseCost = 2500,
CostMultiplier = 2f,
SortOrder = 1,
Icon = UpgradeIcon.FromPng(IconData.Heart),
DescribeLevel = lvl => $"+{lvl * BonusPerLevel * 100:0}% on every sale",
// The gate. Note what it does NOT need: any knowledge of the game's
// internals. It asks the public API about another node, and that is
// the whole dependency.
IsUnlocked = () => TechTreeApi.GetLevel(BulkDiscount) > 0,
LockedReason = () => "Buy Bulk Discount first",
OnLevelChanged = lvl => Log.LogInfo($"Loyal Shoppers is now level {lvl}"),
});
if (!loyal.Success) Log.LogError($"Loyal Shoppers refused: {loyal.Result}");
// Optional. Without this the English DisplayName is what every language
// gets. The key is the one Tech Tree built from the id: "<id>.name".
tree.AddTranslations("pt-BR", new Dictionary<string, string>
{
[BulkDiscount + ".name"] = "Desconto por Volume",
[LoyalShoppers + ".name"] = "Clientes Fiéis",
});
// The cashback is paid a beat after the purchase, and a Harmony patch has
// no way to wait - so the delay lives in an injected MonoBehaviour.
ClassInjector.RegisterTypeInIl2Cpp<CashbackScheduler>();
CashbackScheduler.Create();
// Nothing to do with Tech Tree: this installs the patch in Effects.cs,
// which is where the levels above actually do something.
var harmony = new Harmony(Guid);
harmony.PatchAll(typeof(Plugin).Assembly);
// Worth logging what actually got patched. A patch that fails to install
// is silent otherwise: the node works, the effect never fires, and there
// is nothing in the log pointing at the cause.
var names = new List<string>();
foreach (var m in harmony.GetPatchedMethods())
names.Add($"{m.DeclaringType?.Name}.{m.Name}");
Log.LogInfo($"Loaded. Applied {names.Count} Harmony patch(es): {string.Join(", ", names)}");
}
}Effects.cs
Both nodes touch the same place - the game's money flow - so one patch covers them, switching on the transaction type.
It pays a cashback instead of shrinking the bill, and that is not a stylistic choice. See the note under the code: it is the most useful thing on this page.
using HarmonyLib;
using TechTree.Api;
namespace ExampleTechTreeMod;
// This file is the actual mod. Plugin.cs only draws buttons.
//
// It is a POSTFIX and a refund, not a Prefix that shrinks the bill. The obvious
// version does not work in this game - see the note under the code.
[HarmonyPatch(typeof(MoneyManager), nameof(MoneyManager.MoneyTransition))]
internal static class MoneyPatch
{
// Our own refund goes through MoneyTransition too, which lands right back in
// this postfix. Without this flag that is infinite recursion and a hung game.
private static bool _refunding;
private static void Postfix(float amount, MoneyManager.TransitionType type)
{
if (_refunding) return;
float back = 0f;
// Buying stock arrives NEGATIVE. Flipping the sign gives what the player
// just paid, and we hand a slice of it back.
if (type == MoneyManager.TransitionType.SUPPLY_COSTS && amount < 0f)
{
int lvl = TechTreeApi.GetLevel(Plugin.BulkDiscount);
if (lvl > 0) back = -amount * Plugin.DiscountPerLevel * lvl;
}
// A sale arrives POSITIVE, so this one is a straight bonus on top.
else if (type == MoneyManager.TransitionType.CHECKOUT_INCOME && amount > 0f)
{
int lvl = TechTreeApi.GetLevel(Plugin.LoyalShoppers);
if (lvl > 0) back = amount * Plugin.BonusPerLevel * lvl;
}
// The game fires transactions with amount 0, and below a cent nothing
// changes on screen.
if (back < 0.01f) return;
// Queued, not paid here: paying inside this postfix lands while the game
// is still animating the money counter, and the refund gets swallowed.
CashbackScheduler.Schedule(back, type);
}
// Called by the scheduler about a second later.
internal static void Pay(float amount, MoneyManager.TransitionType type)
{
if (!MoneyManager.HasInstance) return;
_refunding = true;
try
{
// Same transaction type on purpose, so the refund lands under the
// same heading in the end-of-day statistics.
MoneyManager.Instance.MoneyTransition(amount, type, true);
}
catch (System.Exception e)
{
Plugin.Log?.LogWarning($"cashback failed: {e.Message}");
}
finally
{
// Must always run: a stuck flag kills the effect silently until the
// game restarts.
_refunding = false;
}
}
}The obvious version of this is a Prefix that shrinks the bill before the game charges it:
private static void Prefix(ref float amount, TransitionType type)
That was the first attempt, and it does not work in this game. Measured, not guessed - the diagnostic said:
[diag] MoneyTransition type=SUPPLY_COSTS amount=-420,80 bulkLvl=3
The patch installed, the call arrived, the type was right, the level was right, and the player
was still charged the full amount. The method is a managed proxy over native code, and writing to a
ref parameter does not carry into the native call. Reading the
arguments works fine - which is why that log line is correct.
So the effect lets the game charge whatever it wants and hands part of it back with a second transaction. Same money in the player's pocket, built only on what is known to work.
The refund goes through
MoneyTransition too, so it lands right back in this postfix. Without
_refunding that is infinite recursion and a hung game - and the
finally matters just as much: if the call ever throws, the flag has to
go back to false or the effect stops working silently until the game restarts.
GetLevel
is called when the money moves, not cached in a field. It is a dictionary lookup, so it costs next
to nothing, and it can never drift out of sync with what the player owns.
No save code, no UI code, no localisation plumbing, no affordability check, no sell-back handling. The interesting parts of this mod are the effect and the timing - everything else came for free.
CashbackScheduler.cs
The refund is paid about a second after the purchase, and that needs a home. A Harmony
patch runs when the game calls it and returns - it has no way to wait, and sleeping the thread
would freeze the game. So the delay lives in an injected MonoBehaviour
with an Update.
internal sealed class CashbackScheduler : MonoBehaviour
{
// Every MonoBehaviour injected into IL2CPP needs this constructor. Without it
// the type registers but the runtime cannot build an instance.
public CashbackScheduler(IntPtr ptr) : base(ptr) { }
internal const float Delay = 1.25f;
private sealed class Pending
{
public float At;
public float Amount;
public MoneyManager.TransitionType Type;
}
// A queue, not a single pending value: the player can order twice in a row,
// or a sale can land mid-wait. One variable would drop the first one.
private static readonly List<Pending> Queue = new();
private static CashbackScheduler _instance;
internal static void Create()
{
if (_instance != null) return;
var host = new GameObject("ExampleCashbackScheduler");
// Without this the object dies on the menu -> store scene change and
// Update silently stops running.
UnityEngine.Object.DontDestroyOnLoad(host);
host.hideFlags = HideFlags.HideAndDontSave;
_instance = host.AddComponent<CashbackScheduler>();
}
internal static void Schedule(float amount, MoneyManager.TransitionType type)
{
if (amount < 0.01f) return;
// unscaledTime: if anything pauses or slows the game, the payment still
// goes out instead of getting stuck in the queue.
Queue.Add(new Pending { At = Time.unscaledTime + Delay, Amount = amount, Type = type });
}
private void Update()
{
// This runs 60+ times a second for the whole session and almost always
// has nothing to do.
if (Queue.Count == 0) return;
float now = Time.unscaledTime;
// Backwards, because entries are removed while walking the list. Forwards,
// removing index 0 shifts everything down and the loop skips an item.
for (int i = Queue.Count - 1; i >= 0; i--)
{
var p = Queue[i];
if (now < p.At) continue;
// Removed before paying: if the payment throws, it does not sit in
// the queue retrying every frame forever.
Queue.RemoveAt(i);
MoneyPatch.Pay(p.Amount, p.Type);
}
}
}The first version paid the refund immediately, in the patch. The maths was right and the player barely saw the money come back.
The game animates the money counter when it changes. A second transaction fired inside the first one lands mid-animation: the two values fight, and the refund either flickers or disappears. From the player's side that is indistinguishable from the mod doing nothing.
Waiting about a second lets the charge finish animating, and then the refund is its own movement - which also reads better, because the player sees money come back instead of seeing a smaller number leave.
IconData.cs
The PNG lives in the assembly as base64 - a loose file next to the dll is a file players lose. Decoding is lazy, so nothing touches Unity at load time.
using System;
namespace ExampleTechTreeMod;
internal static class IconData
{
private static byte[] _bytes;
public static byte[] Heart => _bytes ??= Convert.FromBase64String(Base64);
// White artwork with alpha: Tech Tree tints the icon through Image.color,
// so a coloured PNG would come out multiplied and wrong.
private const string Base64 =
"iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAB9klEQVR42u3cUW7DMAwE0azR" +
"...";
}Trying it
dotnet build ExampleTechTreeMod.csproj -c Debug- Close the game, then copy the dll into
BepInEx/plugins/ - Load a save, sit at the store computer, open Tech Tree
- A Mods tab appears, with an Example sub-tab
- Buy Bulk Discount I - Loyal Shoppers unlocks within a third of a second
- Order some stock: the full amount leaves, and about a second later the cashback lands
Worth knowing, because it will happen to you:
the bonus node pays through CHECKOUT_INCOME, and a tipping mod running
alongside reads that same event to decide when to tip. It saw the bonus as a sale and tipped on top
of it.
Neither mod is wrong - both guard against their own transactions, and neither knows the other exists. If your effect produces the same kind of event it observes, expect this.
The test that matters
This is the one worth doing before you publish anything, because it is the scenario nobody tests and everybody hits:
- Buy a couple of levels.
- Close the game and delete your dll.
- Start the game, load the same save, buy any built-in upgrade to force a save, quit.
- Open
BepInEx/config/TechTree/slot0.dat. Your lines must still be there, under kept for mods that are not currently installed. - Put the dll back. Your nodes return at the level they were.
If step 4 fails, the player who tries your mod and removes it has permanently lost what they paid. How this works
Where to get it
The full project - csproj, README and all three source files - is in the
examples/ExampleTechTreeMod/ folder of the Tech Tree source. It is also
the easiest starting point: copy the folder, change the guid, change the ids, delete the nodes you
do not want.