Mega Tree API

Requirements

Gating a node behind a condition or a store level, why the predicate is polled three times a second, and what happens when it throws.

Two kinds of gate

FieldAnswers
IsUnlocked + LockedReason Anything you can write in C#. Your condition, your wording.
RequiredStoreLevel Just the store level, per level of your line. Mega Tree writes the reason.

They combine. A node with both is buyable only when the predicate is true and the store level is high enough, and the card shows the level requirement first - it is a number the player can check for themselves.

IsUnlocked

csharp
new UpgradeDefinition
{
    Id           = "bigboxes.auto",
    // ...
    IsUnlocked   = () => MegaTreeApi.GetLevel("bigboxes.capacity") >= 2,
    LockedReason = () => "Needs Bigger Boxes II",
}

While IsUnlocked returns false the whole line is drawn grey and cannot be bought, and the hover card shows LockedReason instead of the effect text. When it flips to true the node becomes buyable within about a third of a second - the player does not have to reopen anything.

Always give a reason

LockedReason is optional, and leaving it out means the card shows a bare "Locked". That is barely better than blank, and a grey node with no explanation is indistinguishable from a bug - which is what lands in your comments section.

How often it is called

The tree repaints node states about three times a second, and asks every visible node whether it is unlocked. Mega Tree caches your answer for 0.25 seconds per node, so a predicate is called at most about four times a second no matter how many nodes are on screen.

That still means hundreds of calls a minute while the window is open. Keep it cheap.

FineNot fine
Comparing an int you already haveResources.FindObjectsOfTypeAll<T>()
MegaTreeApi.GetLevel(...)FindObjectsOfType<T>()
Reading a singleton fieldReading a file, or anything allocating per call
A cached bool your own code updates on an eventLINQ over a scene-wide collection

If you must look at the scene

Do the expensive part on your own schedule and let the predicate read the answer:

csharp
private static bool _hasFreezer;

// Recomputed twice a second on Mega Tree's own pass, not on every repaint.
OnApply = () =>
{
    _hasFreezer = UnityEngine.Object.FindObjectsOfType<Freezer>().Length > 0;
},

IsUnlocked = () => _hasFreezer,

RequiredStoreLevel

Given a 1-based level of your line, return the store level needed to buy it. 0, or leaving the field null, means no requirement.

csharp
// One step per level
RequiredStoreLevel = lvl => lvl * 10,

// Or an explicit table, which is easier to balance and to read back
RequiredStoreLevel = lvl => lvl switch { 1 => 5, 2 => 12, _ => 20 },

The number is the game's own player level - the one that gates hiring and store growth. Mega Tree reads it from ExperienceManager, so it is the same number the player sees in their own UI, and the card says "Needs store level 12" without you writing that string.

The player can turn this off, for everyone

StoreLevelRequirements in BepInEx/config/MegaTree.cfg disables level gating across the whole tree. When it is off your requirement is ignored along with everyone else's.

You do not need to handle that case, and you should not try to work around it. Someone who turned it off asked for exactly this.

Failure is open, not closed

If your predicate throws, Mega Tree treats the node as unlocked and logs a warning naming your node and plugin.

Why fail-open

The two failure modes are not symmetric. A node wrongly buyable is a mod bug with an obvious cause. A node locked forever with no explanation is a player staring at a grey circle they can never buy, filing a report against the wrong mod. Between those, the noisy one is better - and a mod poking a hole in its own gate is a problem for the mod author, not for the player.

Repeated identical exceptions stop being logged after five lines, so a predicate that throws every quarter second does not fill the log file.

Common gates

Depend on another of your nodes

csharp
IsUnlocked   = () => MegaTreeApi.GetLevel("bigboxes.capacity") >= 2,
LockedReason = () => "Needs Bigger Boxes II",

Depend on a Mega Tree built-in

Built-in ids are stable and have no dot: walk, sprint, reach, push, cashier, fullday, training, and the six mv_* movement branches.

csharp
IsUnlocked   = () => MegaTreeApi.GetLevel("cashier") >= 3,
LockedReason = () => "Needs Fast Cashier III",

Depend on the store

csharp
IsUnlocked   = () => SingletonBehaviour<ExperienceManager>.Instance.CurrentLevel >= 10,
LockedReason = () => "Available from store level 10",

// ...though for exactly this, RequiredStoreLevel is the better field:
// it writes the reason for you and honours the player's config toggle.

Depend on another mod, safely

csharp
// Compute it once at Awake, so the predicate is just a field read and cannot throw.
private static bool _hasNightOwl;

private void Awake()
{
    _hasNightOwl = Chainloader.PluginInfos.ContainsKey("NightOwl");
    // ...
}

IsUnlocked   = () => _hasNightOwl,
LockedReason = () => "Requires the Night Owl mod",

Locked is not hidden

A locked node is still drawn, still shows its name and its price. That is deliberate: the tree is meant to be read as a plan, and an upgrade the player cannot see yet is an upgrade they will never work towards. If you truly want something invisible until a condition holds, register it late instead of gating it.

Locked versus ComingSoon

IsUnlocked falseComingSoon true
MeansYou can have this, once you meet the conditionThis does not exist yet
Card showsYour LockedReason"Soon"
Can become buyable in-sessionYesNo, it needs a mod update