Authoring Mastery Tracks
Pack-authored mastery tracks: the structured schema, Parent reuse, generators, costs, and the modifier grammar
Server/MMOSkillTree/Masteries/*.jsonMasteries/One file = one track (the filename, lowercased, is the track id). A whole family of tracks is one MasteryGenerators/ file, below.
Prefer a content pack: author one file per track under Server/MMOSkillTree/Masteries/ inside a pack folder, zip it, and drop the .zip into your server Mods/ folder. Building the zip.
A mastery track is one structured file with PascalCase fields at the top level - there is no Name/Payload wrapper. The filename, lowercased, is the runtime track id; Nodes is a map keyed by node id, in display order. It is the same schema wherever a track body appears: a pack file, a generator's Child, or an entry in the owner's mastery.json overlay.
{
"Parent": "mastery_base",
"Target": "skill:SWORDS",
"Requires": { "Factors": [ { "Factor": "hytale:stat", "Param": "MMO_Level_SWORDS", "Min": 16 } ] },
"Nodes": {
"swp_t1": {
"Tier": 1,
"Cost": { "Currencies": { "mastery_point": 3 },
"Items": [ { "Item": "Ingredient_Lightning_Essence", "Count": 1 } ] },
"Modifiers": [ { "Shape": "PERCENT", "ParamKey": "damage", "Value": 0.08,
"TargetSkill": "SWORDS", "CombatTarget": "SWORDS" } ]
}
}
}Track fields
| Field | Type | Default | Description |
|---|---|---|---|
Parent | string | none | Another track (or an Abstract base) to inherit from; see Reuse. |
Abstract | bool | false | true marks a shared base that never becomes a track of its own. Abstract itself never inherits, so a child of a base is a real track. |
Enabled | bool | true | false parks the track without removing the file - in its own file, a later pack's same-id file, or an owner's mastery.json entry; the same leaf switches a shipped-parked track back ON. |
Target* | string | - | "skill:<SKILL_ID>", "ability:<abilityId>", or the bare word "global" (the width a school track wants; see School-scoped modifiers). |
TitleKey | string | none | Display is key-only - convention mastery.<trackId>.title. Raw display literals are not read. |
Icon | string | none | Item id used as the track icon. |
RefundPercent | int | inherits | Falls back to the global mastery.json respec.defaultRefundPercent (50) if unset. A node-level RefundPercent overrides this. |
Requires | object | none | Track-level unlock gate, written as the one shared gate block (skill levels, quests, other tracks' nodes, and so on). |
Nodes | map | {} | The purchasable nodes, keyed by node id, in display order. A node id is what a player's purchases are saved under, so renaming one orphans everybody who bought it. |
Node fields
Each entry under a track's Nodes map:
| Field | Type | Default | Description |
|---|---|---|---|
Tier | int | 0 | Visual tier row. 9 is the shipped convention for an "Eternal" repeatable capstone. |
TitleKey / DescriptionKey | string | none | Key-only, same as the track - convention mastery.<nodeId>.title. Without a DescriptionKey the effect line auto-renders from Modifiers. |
Icon | string | none | Item id. |
ChoiceGroup | string | none | Siblings sharing a ChoiceGroup are mutually exclusive (pick-one branching tier). |
Prerequisites | string[] | [] | Other node ids in this same track that must already be owned. |
Requires | object | none | External gate (a skill level, another track's node, a completed quest), written as the one shared gate block. |
Cost | object | free | The shared price group: Currencies (a {currencyId: amount} map), Items ([{Item, Count}]), and Combine (All charges every component, the default; Any charges exactly one route). |
StatSacrifice | object | none | A permanent max-stat drain paid BESIDE the price: { "Stat": "HEALTH"|"STAMINA"|"MANA", "Mode": "permanent_max", "Amount": N }. Single-purchase nodes only; see the callout below. |
Modifiers | modifier[] | [] | The effect grammar, see the shapes below. |
MaxPurchases | int | null (single-purchase) | -1 = infinite ("Eternal"); >1 = capped repeat count. A repeatable node takes FLAT and PERCENT modifiers only. |
CostScaling | object | null (static cost) | Only meaningful with MaxPurchases != 1: { Curve, Multiplier, SoftCap }. Curve is EXPONENTIAL (base * multiplier^count, default) or POLYNOMIAL (base * (count+1)^multiplier). SoftCap (default 0 = none) freezes scaling past that purchase count. |
RefundPercent | int | inherits track/global |
Track and node gates
A track's or a node's Requires is the same gate block a quest, a shop entry, a board band or an NPC placement uses, so one spelling covers every kind of condition and a mastery gate can read anything the rest of the server can.
- A skill level is a bound on that skill's level reading: factor
hytale:stat, paramMMO_Level_<SKILL>. - Another mastery node is factor
mmoskilltree:mastery_node, param"<track>:<node>", minimum 1. AnyOfopens the node behind either of two earlier picks;AllOfandNotcompose the rest.
"Requires": {
"Factors": [ { "Factor": "hytale:stat", "Param": "MMO_Level_MINING", "Min": 40 } ],
"AnyOf": [
{ "Factors": [ { "Factor": "mmoskilltree:mastery_node",
"Param": "fireball_mastery:fb_t2_radius", "Min": 1 } ] },
{ "Factors": [ { "Factor": "mmoskilltree:mastery_node",
"Param": "fireball_mastery:fb_t2_pierce", "Min": 1 } ] } ]
}A locked track or node lists every requirement the player has not met, not just the first one, so a well-authored gate reads as a shopping list in game. The mastery audit domain (/mmoconfig validate) checks every ability id, skill id, paramKey and cross-node gate reference a track makes, so a dangling one is reported instead of silently inert.
Node StatSacrifice
A mastery-only cost component paid in a stat, sitting on the node BESIDE the Cost group: { "Stat": "HEALTH"|"STAMINA"|"MANA", "Mode": "permanent_max", "Amount": N }.
Forbidden on a repeatable node
StatSacrifice on a repeatable node (MaxPurchases != 1) is invalid. The load check drops the whole track, not just the offending node, and logs a warning. permanent_max lowers the stat's max cap permanently (reversed on respec); the floor per stat is configurable in mastery.json (statSacrifice.minRemaining, default 1).Costs & respec
Cost is the shared price group every other priced thing on the server speaks: Currencies, Items as [{Item, Count}], and Combine where Any charges exactly one route. Omitting Cost entirely defaults a node to free.
Respec refunds a percentage of everything spent on a track, walking node RefundPercent to track RefundPercent to the global respec.defaultRefundPercent in mods/mmoskilltree/mastery.json. A StatSacrifice's permanent_max reduction is restored in full on respec regardless of refund percent (the stat cap itself is not a currency to be partially refunded).
The modifier grammar
Modifiers entries are the same grammar an ability improvement uses (the grantable Mmo_Ability_Mod reward) - see Authoring Abilities for the shapes and the paramKey vocabulary for the 17 names a modifier's ParamKey has to resolve against, plus how a pack declares one of its own.
| Field | Type | Default | Description |
|---|---|---|---|
Shape | enum | FLAT | FLAT / PERCENT / SET / TRIGGER / ADD_STEP, below. On a repeatable node (MaxPurchases != 1) only FLAT/PERCENT are allowed, and a Condition is not - N purchases would splice N copies; a violation drops the whole track at load. |
ParamKey* | string | - | The parameter the value lands on. Four never touch an ability at all: lootMultiplier (PERCENT, luck-roll bonus, needs TargetSkill) and schoolResist (PERCENT only, needs School - see School-scoped modifiers), plus lifesteal and comboFinisherBonus, which are summed at the weapon swing. Every other key lands on an ability leaf that declares it. |
Value | number | optional | Required for FLAT/PERCENT/SET. FLAT: base + value. PERCENT: base * (1 + value) (e.g. 0.05 = +5%). SET: replaces the value, last-write-wins. |
TargetSkill | string | none | Scopes this modifier to abilities whose XpRouting.Skills contains this skill. |
TargetAbility | string | none | Scopes to one specific ability id instead of a whole skill. |
CombatTarget | string | none (ability-cast-only) | One of ALL/MELEE/RANGED/a skill id. Absent = the modifier only applies on ability casts. Set = it also applies to plain weapon swings of that combat category. |
School | string | none | One of the nine damage schools. Legal only on the damage and schoolResist keys, and mutually exclusive with the other scope fields - a school-scoped value pays out on every hit of that school, whatever produced it. See School-scoped modifiers. |
Condition | object | none | Gates any modifier on runtime state (single-purchase nodes only): nested SelfHp/TargetHp groups (each {Min, Max} as 0-1 fractions) plus HostileOnly/OnCritOnly/Filter. |
School-scoped modifiers
A damage or schoolResist value can be narrowed to one of the nine damage schools instead of a skill or an ability, so a node reads "+5% Fire damage" or "+4% Void resistance" rather than naming any particular attack. damage mirrors onto the attacker-side MMO_Bonus_<School>/MMO_BonusFlat_<School> pair (FLAT or PERCENT); schoolResist mirrors onto the victim-side MMO_SchoolResist_<School> channel (PERCENT only) - see Gear stats: per-school stats for the same channels gear reaches.
A school bonus is decided by the hit's damage school, not by which skill threw it, so a school track wants "Target": "global": a skill-scoped track only emits for abilities inside its own skill, and the abilities that carry one school are scattered across several skills.
+5% Fire damage on every hit of that school, whatever produced it:
{ "Shape": "PERCENT", "ParamKey": "damage", "School": "Fire", "Value": 0.05 }+4% Void resistance, worn on the victim side:
{ "Shape": "PERCENT", "ParamKey": "schoolResist", "School": "Void", "Value": 0.04 }A school-scoped modifier is refused at load if it...
- names a school nobody recognizes - the error lists the nine legal ids
- carries a
Condition(a school value reaches a hit only through its own stat channel, which no per-hit condition can write) - combines
SchoolwithTargetAbility,TargetSkill, orCombatTarget- one value takes one route - lands on any key besides
damageorschoolResist- no other key has a per-school channel - is a school-scoped
damagein a shape besides FLAT or PERCENT - is
schoolResistwith noSchool, or in a shape besides PERCENT
StatSacrifice.The five shapes, one worked example each
FLAT - adds a flat amount to the target leaf (allowed on repeatable nodes):
{ "Shape": "FLAT", "ParamKey": "damage", "Value": 5, "TargetAbility": "sword_slash" }PERCENT - multiplies the target leaf (allowed on repeatable nodes; the loot-multiplier convention):
{ "Shape": "PERCENT", "ParamKey": "lootMultiplier", "Value": 0.03, "TargetSkill": "MINING" }SET - replaces the target leaf outright, or injects a cast parameter the base ability did not author (single-purchase only):
{ "Shape": "SET", "ParamKey": "cooldownMs", "Value": 4000, "TargetAbility": "dash" }A conditioned modifier is any of the shapes above carrying a Condition (single-purchase only) - "+25% damage to targets under 30% health" is one PERCENT line:
{ "Shape": "PERCENT", "ParamKey": "damage", "Value": 0.25,
"TargetAbility": "sword_slash",
"Condition": { "TargetHp": { "Max": 0.3 } } }TRIGGER - subscribes the player to an EXISTING passive ability's own PassiveTrigger group (event/filter/weapon/HP gates), rather than injecting a new effect (single-purchase only). TargetAbility names that passive ability's id - it must already exist in the ability catalog as a passive asset:
{
"Shape": "TRIGGER",
"TargetAbility": "bleed_on_hit_passive",
"TriggerEvent": "HitDealt"
}ADD_STEP - splices an extra piece of chain into the target ability's Body (single-purchase only). InsertAnchor is one of START, END or ON_HIT, matching the anchor nodes the target body authors, and StepPayload is the id of an interaction fragment your pack ships, not an inline block of steps:
{
"Shape": "ADD_STEP",
"TargetAbility": "sword_slash",
"InsertAnchor": "ON_HIT",
"StepPayload": "MyPack_Frag_Bleed_Pulse"
}/mmoconfig validate rather than quietly doing nothing. See Authoring Abilities for the anchors and how to author the fragment it points at.Reuse: Parent and Abstract
Reuse is native Parent inheritance, the same mechanism quests and abilities use. A file marked "Abstract": true is a shared base that never becomes a track of its own; a child names it with "Parent": "<id>", retunes any leaf, and inherits the rest. Nodes merges per node id, and inside a node every group merges per leaf, so a child can retune one price and keep the whole tree.
// Server/MMOSkillTree/Masteries/Mastery_Base.json
{ "Abstract": true, "RefundPercent": 50 }
// Server/MMOSkillTree/Masteries/Axes_Focus.json
{ "Parent": "mastery_base", "Target": "skill:AXES",
"Nodes": { "axe_t1": { "Tier": 1, "Cost": { "Currencies": { "mastery_point": 3 } },
"Modifiers": [ { "Shape": "PERCENT", "ParamKey": "damage",
"Value": 0.08, "TargetSkill": "AXES" } ] } } }Generators: one file, many tracks
Server/MMOSkillTree/MasteryGenerators/<Id>.json writes a FAMILY of tracks - "the same track, once per skill" - exactly the way quest generators do. Substitution is textual and applies everywhere in Child: every string value, every object KEY (which is how each member gets its own node ids), and IdPattern. A value that is exactly one token keeps that token's type, so "Count": "{count}" lands as a number.
{
"Base": "mastery_base",
"IdPattern": "{skillLower}_mastery",
"ForEach": [ { "Values": [
{ "skill": "SWORDS", "skillLower": "swords", "prefix": "swo" },
{ "skill": "AXES", "skillLower": "axes", "prefix": "axe" } ] } ],
"Child": {
"Target": "skill:{skill}",
"Nodes": { "{prefix}_t1": { "Tier": 1,
"Modifiers": [ { "Shape": "PERCENT", "ParamKey": "damage",
"Value": 0.08, "TargetSkill": "{skill}" } ] } }
}
}An axis may name a Source some mod enumerates ("mmoskilltree:skills", with an optional "Filter": {"Category": "COMBAT"}) instead of listing Values, so a family grows with the skill roster. Each combination becomes an ordinary child of Base - the same decode a hand-written child gets - and an authored Masteries/ file of the same id always wins, which is how one member of a family is made special.
The owner overlay: mastery.json
A server owner retunes a shipped track from mods/mmoskilltree/mastery.json (schema v3): each entry under tracks is a partial body in this same schema, merged per leaf over the loaded track of that id, so one price can change and everything else stays the pack's.
{
"schemaVersion": 3,
"tracks": {
"swords_mastery": {
"Nodes": { "swo_t1_dmg": { "Cost": { "Currencies": { "mastery_point": 5 } } } }
},
"parked_track": { "Enabled": false }
}
}Upgrading an older mastery.json
mastery.json (the lower-case target/nodes format, or the retired extends/params DSL) is not read: the server moves it to a timestamped backup/ copy on first start, with one notice. The Migration Converter rewrites an old file into this v3 overlay for you.Minimal example
{
"Target": "skill:MINING",
"TitleKey": "mastery.simple_mastery.title",
"Nodes": {
"smp_t1": {
"Tier": 1,
"Cost": { "Currencies": { "mastery_point": 2 } },
"Modifiers": [
{ "Shape": "PERCENT", "ParamKey": "lootMultiplier", "Value": 0.03, "TargetSkill": "MINING" }
]
}
}
}Exhaustive example
A parked two-node track exercising most leaves, an Eternal capstone included:
{
"Enabled": false,
"Target": "skill:ACROBATICS",
"RefundPercent": 50,
"Requires": {
"Factors": [ { "Factor": "hytale:stat", "Param": "MMO_Level_ACROBATICS", "Min": 5 } ]
},
"Nodes": {
"acr_t1": {
"Tier": 1,
"Cost": {
"Currencies": { "mastery_point": 2, "life_essence": 500 },
"Items": [ { "Item": "Ingredient_Lightning_Essence", "Count": 1 } ]
},
"Modifiers": [
{ "Shape": "PERCENT", "ParamKey": "lootMultiplier", "Value": 0.03, "TargetSkill": "ACROBATICS" }
]
},
"acr_eternal": {
"Tier": 9,
"Prerequisites": ["acr_t1"],
"MaxPurchases": -1,
"CostScaling": { "Curve": "EXPONENTIAL", "Multiplier": 1.1 },
"Cost": {
"Currencies": { "life_essence": 200 },
"Items": [ { "Item": "Ingredient_Lightning_Essence", "Count": 2 } ]
},
"Modifiers": [
{ "Shape": "PERCENT", "ParamKey": "lootMultiplier", "Value": 0.005, "TargetSkill": "ACROBATICS" }
]
}
}
}Eternal capstone convention
9 with MaxPurchases: -1 and a small per-purchase PERCENT bonus is the shipped pattern for an infinitely-repeatable capstone node - the cost climbs via CostScaling so it stays a meaningful currency sink at high purchase counts.