EpochStages — KubeJS & Datapack Guide¶
How to drive EpochStages (stages, gates, ore-remaps, unlocks) without the in-game editor — from KubeJS scripts, datapack JSON, commands, or Java. The in-game editor just writes the same JSON this guide describes, so everything here is the "source of truth" format.
The KubeJS plugin auto-loads only when KubeJS is installed (registered via
kubejs.plugins.txt). Nothing below runs on installs without KubeJS.
The 4 layers (and when to use each)¶
| Layer | Where | Use it for |
|---|---|---|
| KubeJS scripts | kubejs/server_scripts/*.js |
Declaring stages; dynamic grant/check/revoke (gameplay logic) |
| Datapack JSON | kubejs/data/<ns>/<dir>/*.json |
The actual gating rules (mod locks, ore remaps, gates) — reloads with /reload |
| Commands | chat / quest rewards / command blocks | Operator actions; tying quests → stages |
| Java API | another mod | Addons calling StageManager / Unlocks |
Mental model: rules are a blacklist — content assigned to a stage is locked until that stage. Anything not in any rule is available from the start (the "Dark Age"). There is no allow-list.
1. Declaring stages¶
A stage just needs to exist before you can gate things behind it. Two equivalent ways:
KubeJS (server_scripts)¶
// kubejs/server_scripts/stages.js — fires on server start
StageEvents.register(event => {
event.add('dark_age') // id only -> display name = id
event.add('medieval', 'Medieval') // id + display name
event.add('age_of_exploration', 'Age of Exploration')
})
Datapack JSON — kubejs/data/<ns>/epoch_stages/<id>.json¶
The file name is the stage id. Body fields are all optional:
{
"display_name": "Medieval",
"sort_index": 2,
"description": "Iron, early magic, medieval life.",
"requires": ["dark_age"]
}
requires = prerequisite stage ids. /epochstages grant refuses to skip them (use force to override).
2. The Stages binding (runtime API)¶
Available in any server script. All calls go through the stage manager, so they respect the configured ownership mode (global / player / team).
Stages.has(player, 'medieval') // boolean — does the player have it?
Stages.grant(player, 'medieval') // boolean — true if newly granted
Stages.revoke(player, 'medieval') // boolean
Stages.of(player) // Set<String> — every stage the player has
Stages.defined() // Set<String> — every declared stage id
playeris the normal KubeJSevent.player(a ServerPlayer). In global ownership,grant/revokeadvance the shared world state (everyone), not just that player.
Example — grant a stage from a trigger:
// kubejs/server_scripts/progression.js
PlayerEvents.advancement(event => {
if (event.advancement.id == 'minecraft:story/smelt_iron' && !Stages.has(event.player, 'medieval')) {
Stages.grant(event.player, 'medieval')
event.player.tell('You have entered the Medieval age!')
}
})
3. Gating rules (datapack JSON)¶
Drop these under kubejs/data/<namespace>/<dir>/<anything>.json (any namespace works; the importer scans
all of them). Apply changes with /reload.
Matcher tokens (used by items / recipes / mobs / dimensions / ores)¶
| Form | Example | Matches |
|---|---|---|
| exact id | minecraft:iron_pickaxe |
just that id |
| wildcard | minecraft:iron_* |
* = any run, over the full namespace:path |
| tag | #c:ingots/iron |
everything in the tag |
In mod-stage rules,
modsis a list of plain namespaces (e.g.create), not tokens.
3a. Lock whole mods — epoch_mod_stages/¶
{
"stage": "age_of_exploration",
"mods": ["twilightforest", "aether"],
"except_items": ["*:*book*", "*:*guide*"],
"except_recipes": [],
"message": "Locked until the Age of Exploration."
}
except_items / except_recipes = carve-outs that stay available (and visible in JEI/EMI) even though
the mod is locked — e.g. pull one starter recipe out of an otherwise-hidden mod.
3b. Ore substitution — epoch_ore_remaps/¶
While the stage is locked, the ore block is physically swapped (server-side, reversible) to the substitute, so Jade/WAILA/other mods all see the substitute too.
{
"stage": "medieval",
"ores": ["#c:ores/iron", "minecraft:iron_ore", "minecraft:deepslate_iron_ore"],
"substitute_block": "minecraft:coal_ore",
"substitute_drop": "minecraft:coal"
}
"*:*_ore" catch-all can hide
every unknown ore while iron keeps its own stage.
- coal_ore / deepslate_coal_ore are a hardcoded baseline and are never remapped.
3c. Gate items / recipes / mobs / dimensions — epoch_gates/¶
For piecemeal gating (not whole mods). Any category is optional.
{
"stage": "age_of_exploration",
"message": "You can't enter the Nether yet.",
"items": ["minecraft:flint_and_steel", "#c:tools/shears"],
"recipes": ["minecraft:iron_pickaxe"],
"mobs": ["minecraft:zombie", "iceandfire:*"],
"dimensions": ["minecraft:the_nether"]
}
3d. Recipe edits — epoch_recipe_swaps / epoch_recipe_removals / epoch_recipe_adds¶
These exist mainly for the editor. For scripting, prefer KubeJS's native recipe API — it's simpler and more powerful:
ServerEvents.recipes(event => {
event.remove({ id: 'minecraft:iron_pickaxe' })
event.shapeless('minecraft:stick', ['minecraft:oak_planks'])
})
4. Per-content unlocks (reveal one thing early)¶
Besides whole stages, you can unlock a single gated item/ore for a player — the unlock: system. This
is how a quest reward can hand you iron_ore while everything else stays gated.
- Command:
/epochstages unlock <players> <token>(andlockto undo) - Java:
Unlocks.unlock(player, "minecraft:iron_ore") - The token is matched against gated content ids — exact,
*wildcard, or#tag.
5. Commands (/epochstages, op level 2)¶
| Command | Effect |
|---|---|
grant <players> <stage> |
grant (refuses if a prerequisite is missing) |
force <players> <stage> |
grant, ignoring prerequisites |
revoke <players> <stage> |
remove a stage |
clear <players> |
remove all stages |
list <player> |
show the player's stages + unlocks |
unlock <players> <content> |
per-content unlock (see §4) |
lock <players> <content> |
undo an unlock |
editor |
open the in-game editor |
6. Tying it to FTB Quests¶
Simplest path — no scripting needed: give the quest a Command reward:
(or do the same from KubeJS in whatever event you prefer withStages.grant).
7. Java API (for an addon mod)¶
StageManager.grant(player, "medieval"); // grant / grantForced / revoke / clear
StageManager.hasStage(player, "medieval"); // boolean
StageManager.getStages(player); // Set<String>
StageManager.globalHasStage(server, "medieval"); // global-ownership query
StageManager.missingPrerequisite(player, "x"); // null if ok, else the missing stage id
Unlocks.unlock(player, "minecraft:iron_ore"); // per-content unlock: pseudo-stage
Unlocks.isUnlocked(player, ResourceLocation.parse("minecraft:iron_ore"));
8. Applying changes¶
| You changed… | To apply |
|---|---|
kubejs/data/** rule JSON |
/reload (ore swaps re-apply to loaded chunks automatically) |
StageEvents.register (declared stages) |
restart the server (it fires on server start) |
Stages.* runtime calls |
live, no reload |
| ownership mode (config) | restart / re-apply config |
Quick reference — directories¶
kubejs/server_scripts/ Stages binding + StageEvents.register
kubejs/data/<ns>/
epoch_stages/<id>.json stage definitions (id = file name)
epoch_mod_stages/*.json lock whole mods
epoch_ore_remaps/*.json ore substitution
epoch_gates/*.json gate items/recipes/mobs/dimensions
epoch_recipe_swaps|removals|adds/ (prefer KubeJS native recipes instead)