Define a Tribulation
A tribulation is the price of a breakthrough: a trial consumed beat by beat, where surviving it means the next realm and failing it is settled by whatever you wrote. It is built from two halves — the definition of a tribulation and a breakthrough that references it. Nothing starts a tribulation on its own.
This tutorial hangs a lightning tribulation on the example pack's foundation stage.
What You Are Building
| File | Purpose |
|---|---|
data/example/mxt/tribulation/heavenly_gate.json | The tribulation: wind-up, timeline, success and failure. |
data/example/mxt/realm_stage/foundation.json | (edit) Reference the tribulation from that breakthrough. |
Step 1 — A Minimal Timeline
// data/example/mxt/tribulation/heavenly_gate.json
{
"windup": 60,
"timeline": [
{"type": "mxt:idle", "duration": 20},
{"type": "mxt:action", "action": {"type": "mxt:spawn_lightning", "damage": 6}},
{"type": "mxt:idle", "duration": 40},
{"type": "mxt:action", "action": {"type": "mxt:spawn_lightning", "damage": 10}}
],
"success_action": {"type": "mxt:add_resource", "resource": "example:qi", "amount": 50},
"fail_action": {"type": "mxt:damage", "amount": 20}
}| Field | Effect |
|---|---|
timeline | Required, the timeline the run copies into its attachment and then walks with a cursor, at least one beat. |
windup | Ticks to idle through before the first beat starts. |
success_action | Runs once the cursor walks off the end of the timeline; default mxt:no_op. |
fail_action | Runs when a beat cannot continue; default mxt:no_op. |
There are four kinds of beat:
type | Field | Behaviour |
|---|---|---|
mxt:action | action (EntityAction, required) | Runs once and finishes in the same tick. Several adjacent mxt:action beats all run inside one tick, so "a sequence of things that happen at once" does not need splitting up. |
mxt:idle | duration (NumberProvider, required) | Waits that many ticks. |
mxt:wait_for | condition (EntityCondition, required), timeout (ticks, optional), on_timeout (fail by default, or finish) | Checked once per tick; finishes when it passes. With a timeout it is a deadline, and when it runs out the run fails or moves on according to on_timeout. |
mxt:branch | condition (EntityCondition, required), if_true / if_false (absolute indices, optional) | Moves the run's cursor to another beat and finishes in the same tick; a branch that is not written simply advances one beat. |
The timeline is a stored cursor: it is copied into the entity's attachment when the run starts, and one beat is walked per tick, advancing each time it finishes. The cursor is saved (mxt:branch can move it to any beat, backwards included), so a /reload cannot change a run already in progress — but difficulty_scale and the two outcome actions are still read from the definition live. The if_true / if_false indices count from the first beat of the copied timeline (0-based), and an index out of range is refused at start.
mxt:wait_for can have a deadline
Without a timeout there is no deadline: if the condition never passes, the tribulation waits on that beat forever — it neither advances nor fails — so keep conditions reachable (something like mxt:exposed_to_sky rather than a combination that may never be true).
With a timeout (in ticks) it is a deadline: when it runs out, on_timeout decides — fail (the default) fails the run, finish moves past the beat. It is a deadline rather than the length of a beat, so it is resolved from timeout alone, without difficulty_scale and without the aura modifier: difficulty should not decide how long a player has to meet a condition. A timeout that does not resolve to a positive number rejects the whole start.
Step 2 — Adding a Wind-Up Wait
Both mxt:idle and windup are converted with the same rule:
duration × difficulty_scale × max(0, 1 + aura_tribulation_modifier)difficulty_scaledefaults to1and scales the whole tribulation.aura_tribulation_modifiercomes from the environment: it isrules.tribulation_modifyof the aura zone the run stands in (default0), so the thicker the aura the harder the trial. It is the only formula variable this rule adds, and the multiplier is floored atmax(0, …)— a modifier below-1makes the multiplier0, so the duration fails to resolve and the whole start is refused rather than producing a negative length.- The conversion happens once, when the beat starts: a random duration is rolled a single time, and a change in ambient aura mid-wait neither stretches nor shortens it. The countdown the player sees is therefore the number of ticks that will really pass.
Every beat is asked "can you run now?" before the run starts, and an mxt:idle whose duration cannot be resolved, an mxt:wait_for whose timeout cannot be resolved, or an mxt:branch aimed out of range rejects the whole start — a broken definition must not surface after the player has already paid for the breakthrough.
Step 3 — Splitting Into Two Branches
mxt:branch keeps a timeline from having to be a straight line: in the same tick it moves the cursor to the beat named by if_true or if_false (absolute indices, 0-based), and with neither written it simply advances one beat. The timeline below first waits with a deadline for the player to stand under open sky (timeout: 600 ticks, failing the run if they do not), then splits on health — a low-health player is sent by if_true: 3 straight to the 4th beat, skipping the lighter bolt on the 3rd:
"timeline": [
{"type": "mxt:wait_for", "condition": {"type": "mxt:exposed_to_sky"}, "timeout": 600, "on_timeout": "fail"},
{"type": "mxt:branch", "condition": {"type": "mxt:health", "comparison": "<", "compare_to": 10}, "if_true": 3},
{"type": "mxt:action", "action": {"type": "mxt:spawn_lightning", "damage": 4}},
{"type": "mxt:action", "action": {"type": "mxt:spawn_lightning", "damage": 12}}
]An out-of-range index is refused at start, so a branch pointing at the wrong beat cannot surface only after the player has paid for the breakthrough; backward jumps are allowed too (nothing stops a "go back and take another round while the health is low" loop, so the condition has to be able to settle on its own).
The wind-up differs from a wait only in that it consumes no beats and writes no per-beat state, but the run already counts as in progress: starting another one is still refused, /mxt tribulation status reports "still winding up, N ticks left", and darken_sky already applies. The remaining ticks are saved and synced with the attachment, so logging out and back in continues the countdown rather than restarting it.
Step 4 — Striking With Lightning
mxt:spawn_lightning is an ordinary EntityAction: apart from its colour and thickness it is a vanilla bolt — damage, fire, lightning rods, copper, thunder, the sky flash, and villager→witch, pig→zombified piglin and charged creeper conversions all behave as usual. It therefore works in any EntityAction slot, not just a tribulation.
| Field | Type | Default | Effect |
|---|---|---|---|
damage | NumberProvider | 5 | Bolt damage, the vanilla number by default. |
color | colour | #737380 | RGB, written as #RRGGBB (an integer or an [r,g,b] float array is accepted too) — vanilla's cold white by default. |
palette | colour[] | [] | A gradient from the sky end to the impact point, at most 16 entries; it replaces color. |
alpha | float 0..1 | 0.3 | The bolt's brightness, not transparency: vanilla draws it additively, so RGB × alpha is how hard it glows. |
thickness | float 0.1..4 | 1 | Bolt thickness multiplier. |
visual_only | boolean | false | Strike without damage or fire — atmosphere only. |
cause | boolean | true | Credit a player caster with the strike (which can fire vanilla's channeled_lightning advancement). |
offset_x / offset_y / offset_z | NumberProvider | 0 | Impact offset from the caster. |
A gradient with a bad colour or more than 16 entries fails at decode time rather than being dropped silently; entries blend linearly, and the four layers plus the two forks read the same seams, so a fork matches the trunk at the height it leaves it.
Swapping the second bolt for a gradient makes "this one hurts more" visible at a glance:
{"type": "mxt:action", "action": {
"type": "mxt:spawn_lightning",
"palette": ["#7A5CFF", "#66CCFF"],
"alpha": 0.45,
"thickness": 1.6,
"damage": 10
}}Step 5 — Success and Failure
- Success: once the cursor walks off the end of the timeline,
success_actionruns. The example hands back 50 aura — surviving the trial leaves the pool fuller than before. - Failure: when a beat decides it cannot continue (its
mxt:idlestate is gone, the beat itself reports failure, or anmxt:wait_fortimeoutruns out withon_timeout: "fail"),fail_actionruns. There is no built-in punishment at all — no realm loss, no experience taken — everything is what you write infail_action. - Dying is not failing: the attachment is
copyOnDeath(), so death does not clear a tribulation; it carries on with the respawned entity (a tribulation that keeps striking after death is expected). If it should end with death, arrange that separately, or write the punishment as "dying wastes the attempt". /mxt tribulation stopdoes not settle either: it only clears the run, so neithersuccess_actionnorfail_actionruns. Worth knowing while testing, so a stopped run is not mistaken for a successful one.- Both may be left as
mxt:no_op, in which case the tribulation is pure theatre and the punishment lives elsewhere.
To make failure hurt, compose existing actions — here mxt:damage first, then mxt:add_resource to take experience:
"fail_action": {
"type": "mxt:sequence",
"actions": [
{"type": "mxt:damage", "amount": 20},
{"type": "mxt:add_resource", "resource": "example:qi", "amount": -500}
]
}Step 6 — Hooking It to a Breakthrough
A tribulation never starts itself; the thing that references it decides. Most often that is a realm stage:
// data/example/mxt/realm_stage/foundation.json
"tribulation": "example:heavenly_gate"Then add a gate — condition is evaluated once, when the run is started, and a failure means this particular run does not happen:
// data/example/mxt/tribulation/heavenly_gate.json
"condition": {"type": "mxt:aura_range", "min": 30, "max": 1000}"Thin aura cannot host a tribulation" is thereby a datapack rule rather than a special case in code. When the gate refuses, the breakthrough still happens — there is simply no tribulation; requiring one is the job of realm_stage's breakthrough.conditions.
Verify
Reopen the world first (datapack registries are read while the world loads, so /reload is not enough), then:
/mxt registries validate
/mxt tribulation start example:heavenly_gate
/mxt tribulation status
/mxt tribulation stopvalidateshould report no codec errors.startenters the 60 tick wind-up immediately: the action bar shows "天劫将至:3.0 秒" counting down and the sky starts to darken (about a 1 second fade).Once the wind-up ends, the first beat (
mxt:idle 20) starts;statusthen reports "beat 1" and prints that beat's state, for example{"remaining":12,"type":"mxt:idle_countdown"}. This is the most direct window onto the timing while tuning it.Two bolts land,
success_actionpays 50 aura, and the sky fades back.You can also try it without logging in, on any live entity nearby (a tribulation hangs off a living entity and is advanced by its tick):
text/execute positioned 0 100 0 run mxt tribulation start example:heavenly_gate @e[tag=probe,limit=1] /execute positioned 0 100 0 run mxt tribulation status @e[tag=probe,limit=1]Finally run the real path:
/mxt resource example:qi set 500to fill the pool, then/mxt breakthrough example:qi— the same wind-up should appear.
Common Mistakes
| Symptom | Cause |
|---|---|
| Nothing happens on a breakthrough | No tribulation on the realm stage, or the tribulation's condition does not pass. |
start says a tribulation is already running | This entity already carries one — including a run still in its wind-up; stop to clear it. |
start says a beat cannot run now | The pre-start validation failed, most often an mxt:idle whose duration cannot be resolved (a formula using a variable the empty context does not provide); it can also be an mxt:wait_for whose timeout does not resolve to a positive number, or an mxt:branch whose if_true / if_false is outside the timeline. |
| The tribulation is stuck on one beat | That beat's mxt:wait_for condition is unreachable and it has no timeout — so it neither advances nor fails; give it a deadline (a timeout plus on_timeout) to give it an end. |
mxt:branch jumped to the wrong place | Indices are absolute (counted from beat 1 of the copied timeline, 0-based), not an offset from the current beat; an out-of-range value is refused at start. |
| It stopped advancing on its own | Advancement is driven by the entity's tick, so it pauses in unloaded chunks (the attachment is saved) and resumes where it left off. |
difficulty_scale of 0 or a negative number | Not a load error, but waits resolve to -1: with any mxt:idle present the start is refused (invalid_entry); a timeline of only mxt:action beats instead "succeeds" instantly. |
A formula using bare level gives 0 | Tribulation formulas run in an entity context, where level / realm_rank (which need a resource context) are unavailable. Use caster_level for the caster's vanilla experience level. |
| A timeline edit does not change a run in progress | The timeline is copied into the attachment when the run starts, so /reload only affects the next one. |
Writing mxt:entry_began / mxt:idle_countdown with mxt:modify_ability_storage does nothing | Those two kinds belong to the tribulation's own timeline state and no ability type declares them, so mxt:modify_ability_storage cannot write them; only a WARN is logged. |
| The sky does not darken | darken_sky is written as false, or no player nearby is running a tribulation — both the darkening and the countdown read the attachment on players: your own run always counts, another player's counts within 160 blocks, and a tribulation on a mob takes no part. |
Next
- tribulation — the full field list and timeline details.
- realm_stage —
tribulationand breakthrough conditions. - Entity Actions — everything the outcome actions can do,
mxt:spawn_lightningincluded. - Commands — the rest of
/mxt tribulationand/mxt lightning.