LogoKubeJS PMMO

Tag Model

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
|
v
PMMO event handler builds an input CompoundTag
|
v
trigger listeners may mutate or return runtime context
|
v
PMMO builds each perk's merged settings tag
|
v
perk start may write result output for later perks
|
v
PMMO computes XP, requirements, damage, speed, or other final effects
|
v
xp 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:

NeedWrite hereWhy
A stable pack rule for items, blocks, entities, biomes, or dimensionsPmmoJS.settings(...)PMMO can load and evaluate it as normal object data
A reusable NBT path or comparator aliasPmmoJS.globalsConfig(...)PMMO only expands globals inside NBT paths and NBT comparators
A configurable custom perk knobPmmoJS.registerPerk(...).withInt(...), .defaults(...), then optionally PmmoJS.perksConfig(...)PMMO merges defaults and perk config before callbacks run
State that tick() or stop() must read laterctx.getSettings()PMMO stores a copy of the settings tag in the active tick schedule
Output for later perks in the same execution passctx.getResult() inside start()PMMO merges the start result into the execution output
Extra XP or cancellation for one PMMO triggerPmmoJS.trigger(...) context helpersThis context belongs to one trigger dispatch
Skip PMMO's handler without blocking the game actionPmmoJS.internal(...).skipPmmo()Internal hooks control PMMO handler execution
Cancel the wrapped Forge actionPmmoJS.internal(...).deny()This sets action cancellation and skips PMMO
Inspect final XP changesPmmoJS.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

CategoryExamplesLifetime
Static object dataRequirements, XP awards, bonuses, effects, salvage, vein dataStored in PMMO data/config
Static perk dataStartup defaults and perks.toml entriesStored until PMMO reloads config
Runtime trigger contextPmmoJS.trigger(...) event contextOne trigger dispatch
Runtime internal contextPmmoJS.internal(...) event contextOne internal hook dispatch
Perk execution settingsctx.getSettings()One perk activation, then the active tick schedule copy
Perk result outputctx.getResult()One start() call and the current perk execution pass
XP event contextPmmoJS.xp(...).getContext()One XP event

Perk Settings vs Result

When PMMO executes perks, it builds a fresh settings tag for each configured perk entry:

  1. The registered perk's propertyDefaults().
  2. The matching perk config entry from PMMO's perk settings.
  3. The incoming event data.
  4. Output from earlier perks in the same execution pass.
  5. A final injected level key based on the merged skill key.

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 in start() and starts empty.
  • PmmoJS returns a copy of ctx.getResult() from start().
  • PmmoJS returns empty tags from the JS-friendly tick() and stop() overloads, so write lifecycle state to ctx.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() reads skill.
  • getResolvedLevel() reads injected level.
  • getCooldown() reads cooldown.
  • getDuration() reads duration.
  • getChance() reads chance.

Merge Models

PMMO does not use one merge rule everywhere.

PlaceMerge behavior
Perk source tag in PerkRegistry.executePerkCompoundTag.merge(...) layers defaults, config, incoming event data, and prior perk output. Later scalar values overwrite earlier scalar values.
Trigger listener output in EventTriggerRegistry.executeEventListenersTagUtils.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 helpersAward 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) return
if (player.stages.has('festival_mining_bonus')) {
event.addXpAward('mining', 25)
}
if (player.isCreative()) {
event.setCancelled(true)
}
})

Use the wrapper helpers:

  • getContext* and putContext* 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, affectedEntityCount
  • POTION_BREW: alreadyTracked, markBrewed, and sometimes serialized_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.js
PmmoJS.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.js
PmmoJS.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.