PmmoJS exposes PMMO's NBT tags because PMMO uses tags as the hand-off format between object data, triggers, perks, and XP awards. Treat these tags as data packets with different lifetimes. The same CompoundTag type can mean "static config", "current event context", "perk lifecycle state", or "one-shot output" depending on where you see it.
This page is source-backed by PMMO 1.20.1's PerkRegistry.executePerk, EventTriggerRegistry.executeEventListeners, TagUtils.mergeTags, APIUtils.serializeAwardMap, and the PmmoJS wrappers PerkTagJS, PMMOTriggerEventJS, and PMMOInternalEventJS.
Data Flow Map
Most PMMO gameplay paths look like this:
settings / perks config / startup defaults|vPMMO event handler builds an input CompoundTag|vtrigger listeners may mutate or return runtime context|vPMMO builds each perk's merged settings tag|vperk start may write result output for later perks|vPMMO computes XP, requirements, damage, speed, or other final effects|vxp hooks observe the final XP event context
globalsConfig is not a runtime tag in this flow. PMMO uses it while evaluating NBT logic: paths expands #alias before reading object NBT, and constants expands #alias before comparing a value.
The Main Rule
Write data where its lifetime matches the problem:
| Need | Write here | Why |
|---|---|---|
| A stable pack rule for items, blocks, entities, biomes, or dimensions | PmmoJS.settings(...) | PMMO can load and evaluate it as normal object data |
| A reusable NBT path or comparator alias | PmmoJS.globalsConfig(...) | PMMO only expands globals inside NBT paths and NBT comparators |
| A configurable custom perk knob | PmmoJS.registerPerk(...).withInt(...), .defaults(...), then optionally PmmoJS.perksConfig(...) | PMMO merges defaults and perk config before callbacks run |
State that tick() or stop() must read later | ctx.getSettings() | PMMO stores a copy of the settings tag in the active tick schedule |
| Output for later perks in the same execution pass | ctx.getResult() inside start() | PMMO merges the start result into the execution output |
| Extra XP or cancellation for one PMMO trigger | PmmoJS.trigger(...) context helpers | This context belongs to one trigger dispatch |
| Skip PMMO's handler without blocking the game action | PmmoJS.internal(...).skipPmmo() | Internal hooks control PMMO handler execution |
| Cancel the wrapped Forge action | PmmoJS.internal(...).deny() | This sets action cancellation and skips PMMO |
| Inspect final XP changes | PmmoJS.xp(...) | This fires when PMMO applies XP to a player |
If a rule is stable, write it as PMMO data first. Use runtime tags only for live state that static data cannot know.
Tag Categories
| Category | Examples | Lifetime |
|---|---|---|
| Static object data | Requirements, XP awards, bonuses, effects, salvage, vein data | Stored in PMMO data/config |
| Static perk data | Startup defaults and perks.toml entries | Stored until PMMO reloads config |
| Runtime trigger context | PmmoJS.trigger(...) event context | One trigger dispatch |
| Runtime internal context | PmmoJS.internal(...) event context | One internal hook dispatch |
| Perk execution settings | ctx.getSettings() | One perk activation, then the active tick schedule copy |
| Perk result output | ctx.getResult() | One start() call and the current perk execution pass |
| XP event context | PmmoJS.xp(...).getContext() | One XP event |
Perk Settings vs Result
When PMMO executes perks, it builds a fresh settings tag for each configured perk entry:
- The registered perk's
propertyDefaults(). - The matching perk config entry from PMMO's perk settings.
- The incoming event data.
- Output from earlier perks in the same execution pass.
- A final injected
levelkey based on the mergedskillkey.
PMMO builds that tag with CompoundTag.merge(...), so later layers overwrite earlier scalar keys. This is different from TagUtils.mergeTags(...), which is used by trigger output merging.
Inside PmmoJS callbacks:
ctx.getSettings()wraps the merged source tag.ctx.getResult()exists only instart()and starts empty.- PmmoJS returns a copy of
ctx.getResult()fromstart(). - PmmoJS returns empty tags from the JS-friendly
tick()andstop()overloads, so write lifecycle state toctx.getSettings().
This distinction matters:
PmmoJS.registerPerk(event => {event.create('kubejs:heated_pickaxe', PMMOPerkSide.SERVER).withSkill('mining').withDuration(100).withInt('heat_per_tick', 1).start(ctx => {// This survives into tick() and stop() because PMMO copies settings// into the active tick schedule after start() returns.ctx.getSettings().putInt('heat', 0)// This is one-shot output for later perks in this same execution pass.ctx.getResult().putBoolean('heated_pickaxe_started', true)}).tick(ctx => {const settings = ctx.getSettings()settings.putInt('heat', settings.getIntOr('heat', 0) + settings.getIntOr('heat_per_tick', 1))}).stop(ctx => {ctx.getSettings().remove('heat')}).register()})
If you put heat in ctx.getResult(), tick() would not see it. If you put heated_pickaxe_started in ctx.getSettings(), later perks in the same execution pass would not receive it through PMMO's output merge.
PerkTagJS
PerkTagJS wraps CompoundTag with JS-friendly typed helpers.
Reading:
keys(),has(key),hasCompound(key)getString(key),getStringOr(key, fallback)getInt(key),getIntOr(key, fallback)getLong(key),getLongOr(key, fallback)getFloat(key),getFloatOr(key, fallback)getDouble(key),getDoubleOr(key, fallback)getBoolean(key),getBooleanOr(key, fallback)getOrCreateCompound(key)
Writing:
remove(key),clear()putString(key, value),putBoolean(key, value)putInt(key, value),putLong(key, value)putFloat(key, value),putDouble(key, value)putCompound(key, value)putStringList(key, values)putNumberList(key, values)merge(value)copy()
PMMO helper reads:
getSkill()readsskill.getResolvedLevel()reads injectedlevel.getCooldown()readscooldown.getDuration()readsduration.getChance()readschance.
Merge Models
PMMO does not use one merge rule everywhere.
| Place | Merge behavior |
|---|---|
Perk source tag in PerkRegistry.executePerk | CompoundTag.merge(...) layers defaults, config, incoming event data, and prior perk output. Later scalar values overwrite earlier scalar values. |
Trigger listener output in EventTriggerRegistry.executeEventListeners | TagUtils.mergeTags(...) adds numeric values with the same key. For non-numeric duplicate keys, the earlier output value stays. is_cancelled is handled as a boolean "once true, stay true" flag. |
| PmmoJS XP helpers | Award maps are encoded with APIUtils.serializeAwardMap(...) under serialized_award_map. Do not hand-build that compound unless you are matching PMMO's codec format exactly. |
PerkTagJS.merge(value) | Delegates to Minecraft's CompoundTag.merge(...), not PMMO's numeric-summing TagUtils.mergeTags(...). |
Practical result: if two trigger listeners both write bonus_roll as an int, PMMO's trigger output can sum them. If two perk source layers write cooldown, the later layer wins.
Trigger Context
PmmoJS.trigger(type, event => {}) wraps PMMO's trigger registry. Use it when a rule depends on live state from a PMMO EventType.
PmmoJS.trigger(EventType.BLOCK_BREAK, event => {const player = event.getPlayer()if (!player) returnif (player.stages.has('festival_mining_bonus')) {event.addXpAward('mining', 25)}if (player.isCreative()) {event.setCancelled(true)}})
Use the wrapper helpers:
getContext*andputContext*for ordinary context fields.getXpAwards(),setXpAwards(map),setXpAward(skill, amount),addXpAward(skill, amount),clearXpAwards()for XP maps.setCancelled(true)to tell PMMO to skip that trigger's processing.
The XP helper methods exist because PMMO stores XP awards under serialized_award_map, a compound produced by APIUtils.serializeAwardMap(...). A plain object like { mining: 25 } is not the on-wire NBT shape PMMO expects.
Raw direct listeners registered with PmmoHelper.registerTriggerListener(...) are lower-level. PMMO expects the returned tag to include is_cancelled; otherwise the upstream registry treats the listener output as invalid. Prefer PmmoJS.trigger(...) unless you need direct startup-time registration.
Internal Context
PmmoJS.internal(type, event => {}) covers PMMO handler paths that do not go through the trigger registry: dimension travel, login, mount, piston, explosion, player death, potion brewing, and sleep.
Internal hooks start with a new empty CompoundTag. The bridge may seed hook-specific keys before your script runs:
EXPLOSION:affectedBlockCount,affectedEntityCountPOTION_BREW:alreadyTracked,markBrewed, and sometimesserialized_award_map
Use internal hooks to control PMMO's handler, not to replace normal object data:
PmmoJS.internal(PMMOInternalType.POTION_BREW, event => {if (event.getContextBoolean('alreadyTracked')) {return}event.addXpAward('alchemy', 10)event.putContextBoolean('markBrewed', true)})
event.skipPmmo() means the game action continues but PMMO's handler is skipped. event.deny() cancels the wrapped Forge action when it can be cancelled and also skips PMMO.
XP Event Context
PmmoJS.xp(...) fires when PMMO changes a player's XP. Its getContext() exposes PMMO's raw XP event context, so use it for inspection or carefully targeted cancellation. Do not use XP hooks as the main way to define object rewards. Stable rewards belong in PmmoJS.settings(...); runtime additions usually belong in PmmoJS.trigger(...) or PmmoJS.internal(...) through the XP award helpers.
PmmoJS.xp(event => {if (event.getSkill() === 'mining' && event.isLevelUp()) {event.getEntity().tell(`Mining level: ${event.endLevel()}`)}})
Composite Example
This example combines object data, NBT globals, a custom perk, and a runtime trigger without mixing their responsibilities. In a real pack, split it by script phase.
// startup_scripts/pmmo_perks.jsPmmoJS.registerPerk(event => {event.create('kubejs:manyullyn_focus', PMMOPerkSide.SERVER).withSkill('mining').withDuration(80).withInt('bonus_xp', 10).start(ctx => {ctx.getSettings().putBoolean('active', true)ctx.getResult().putBoolean('manyullyn_focus_active', true)}).stop(ctx => {ctx.getSettings().putBoolean('active', false)}).register()})
// server_scripts/pmmo_progression.jsPmmoJS.globalsConfig(event => {event.addPath('tool_materials', 'tic_materials[]')event.addConstant('manyullyn', 'tconstruct:manyullyn')})PmmoJS.settings(event => {event.item('tconstruct:pickaxe').override(true).setRequirement(ReqType.TOOL, 'mining', 5).setXp(EventType.BLOCK_BREAK, 'mining', 4).nbtRequirement(ReqType.TOOL).additive(true).newCase('#tool_materials').equals('#manyullyn', 'mining', 25).done()})PmmoJS.perksConfig(event => {event.addPerk(EventType.BLOCK_BREAK, 'kubejs:manyullyn_focus', 'mining').withMinLevel(30).build()})PmmoJS.trigger(EventType.BLOCK_BREAK, event => {const player = event.getPlayer()if (player && player.stages.has('mining_trial')) {event.addXpAward('mining', 10)}})
The static settings define normal progression. globalsConfig only makes the NBT rule readable. The custom perk keeps lifecycle state in ctx.getSettings() and emits a one-shot marker through ctx.getResult(). The trigger adds a quest-specific XP bonus for one runtime condition.