A Spigot/Paper plugin framework providing structured command systems, event utilities, a staged damage and death pipeline, channel-based chat, packet-based sidebars, tablists, teams and holograms, real-entity NPCs, map-based billboards for images and video, configurable resource packs, a custom item system, an inventory window system, and lifecycle integration built on the Hierarchy-Framework.
Spigot-Plugin-Framework bridges the Bukkit plugin lifecycle with the component-based hierarchy architecture, automatically handling registration and teardown of listeners, commands, and subcommands as components are initialized and shut down.
- Automatic Bukkit registration, so listeners, commands, and subcommands are registered and unregistered through hierarchy lifecycle callbacks
- Type-safe command system with sender validation for Player, Console, or any CommandSender
- Built-in subcommand routing with automatic argument stripping and tab completion delegation
- Cancellable command events at every execution stage, both execute and tab-complete
- Thread-safe event dispatch utilities, synchronous and asynchronous with
CompletableFuturesupport - Task scheduling with ChronoUnit-to-tick conversion: synchronous, asynchronous, and repeating with cancellation suppliers
- MiniMessage-based messaging with configurable prefixes, broadcasting, filtering, and ignore lists
- Packet-based sidebar system with priority resolution, where only changed lines and titles produce packets, giving zero flicker and dynamic animated titles
- Tablist system with priority resolution for per-player header and footer content
- Packet-based team system with per-viewer prefix and suffix resolution, giving relation-aware nametag colours through priority-sorted
Teamsubclasses - Packet-based hologram system built on text displays rather than armour stands, with per-player text, per-player visibility, and no entity in the world
- Hologram backgrounds drawn from a bitmap font glyph, for a textured or shaped panel behind the text
- Packet-based billboard system that shows a PNG, a JPG or a sequence of video frames across a grid of item frames, with no entity or map in the world
- Billboard images sent to each player once per session, and billboard video streamed only to players in range, as the regions that changed between frames
- Real-entity NPC system with typed backing entities, opt-in interaction and damage, respawn gating after death, and no duplicates across restarts or chunk reloads
- Resource pack system driven by a JSON list of packs, each with its own URL, hash, requirement, worlds and permission, sent in one request and swapped per pack on world change
- Staged damage pipeline that replaces vanilla damage entirely, with a gate stage, an ability stage and a reduction stage, each cancellable
- Damage modifiers filed under named keys so a weapon's damage, a critical multiplier and an armour reduction all compose instead of overwriting one another
- Vanilla-accurate defaults for armour, toughness, protection, resistance, strength, weakness, weapon damage, critical hits, knockback, durability and immunity windows, each replaceable per piece or per item through its own event
- Every combat figure tunable in one
Damage.json, from critical multipliers and knockback to the armour formula, applied on the next hit after a reload - Vanilla 1.21 combat by default, with melee damage scaled by attack charge and a shared PvP immunity window, and a config toggle for pre-1.9 combat, where the attack cooldown is removed and immunity is tracked per attacker
- Configurable immunity windows per damage cause, where poison, wither, burning, drowning, freezing and starvation hit at exactly their configured rate rather than vanilla's hardcoded one
- Death system that knows who killed whom and with what, including attributions that outlive the hit that set them, and works with or without the damage pipeline
- Death drops, experience and death sound rewritable per death, with resource pack sounds played in place of vanilla's
- Hit sounds carried on the damage pass, seeded from the entity being struck and replaceable by an ability
- Totem of undying and any other death protection item honoured under custom damage
- Channel-based chat that replaces vanilla chat, with the channel resolved from plugin-owned state on every message, and cancellable send and per-recipient receive events
- Three-part display names, so a consumer takes just the name where a prefix would be noise and the full thing where it would not
- Declarative item system with identity stamping and automatic version reconciliation, so stacks in player inventories and containers update themselves when the definition changes
- Orphaned stacks, whose item is no longer registered, reset to a plain stack or deleted outright per item
- Custom items protected from anvils and enchanting tables, as the item or as the reagent
- Opt-in item activation, so a custom item gains a click action with its own gate, cancellable events, and control over the vanilla behaviour it replaces
- Vanilla-aware activation defaults, so a chest still opens, a hoe still tills, and a consumable is not spent when its item activates
- Sound abstraction held by key rather than enum, so vanilla and resource pack sounds are played the same way
- Inventory window system with slot-bound buttons, open and close gating, and full click and drag protection
- Timed effect system for any living entity, with per-entity amplifier and duration, cancellable add, update and remove stages, and per-tick and expiry hooks
- Effects optionally bound to a vanilla potion effect, kept in sync across every transition, so a custom effect carries its vanilla presentation without the plugin tracking two things
- Duration changes that credit time already served, so refreshing an effect extends what is left instead of discarding it or granting the whole duration again
- NMS utilities for direct packet sending and Adventure-to-vanilla component conversion
- Custom event base classes with cancellation reasons
- Opt-in subsystems through
@Scan, so a plugin enables only the packages it wants - Compatible with Bukkit, Spigot, and Paper
- Designed for modern Java (Java 21+)
SpigotPlugin (extends JavaPlugin, implements Plugin)
└─ Manager
└─ BaseCommand (Node under the Manager)
└─ BaseSubCommand (Node under the command)
Commands and subcommands integrate directly into the hierarchy as Nodes, each with typed access to its parent:
| Component | Hierarchy Role | Bukkit Integration |
|---|---|---|
SpigotPlugin |
Plugin | JavaPlugin lifecycle, component registration |
Manager |
Manager | Organizational grouping |
BaseCommand |
Node under a Manager | Registered with CommandMap |
BaseSubCommand |
Node under a command | Attached to parent command |
The damage, death, chat, effect, sidebar, tablist, team, hologram, billboard, NPC, resource pack, item, and window systems sit outside this hierarchy. Their managers and listeners are framework-owned singletons, discovered through @Scan rather than declared per plugin. See Enabling Subsystems.
Spigot-Plugin-Framework requires Java 21+ and a Paper API environment.
The sidebar, team, hologram and billboard systems and UtilNms use NMS (net.minecraft.server) classes directly. To compile against NMS with Maven, the framework uses the paper-nms-maven-plugin.
Add .paper-nms to your .gitignore, as it contains locally generated dependencies.
After cloning, run the init goal once to generate the NMS dependency in your local .m2 repository:
mvn ca.bkaw:paper-nms-maven-plugin:1.5:init -pl .Note: If
mvnis not on your PATH, you can run it through IntelliJ's Maven tool window: expand Plugins →paper-nms→ double-clickpaper-nms:init.
Note: The init goal requires your
JAVA_HOMEto point to JDK 21. If it fails with a Java version error, set it before running:# PowerShell $env:JAVA_HOME = "C:\Program Files\Java\jdk-21" mvn ca.bkaw:paper-nms-maven-plugin:1.5:init -pl .
The following is only needed at compile time for annotation processing:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<scope>provided</scope>
</dependency>Spigot-Plugin-Framework depends on the following libraries, which are included automatically through Maven:
- Hierarchy-Framework: Plugin, Manager, and Node hierarchy with lifecycle management.
- Dependency Injector: Container management, classpath scanning, and component wiring.
- Utilities: Generic type resolution, string utilities, and casting helpers.
Add the dependency to your Maven project:
<dependencies>
<dependency>
<groupId>io.github.trae</groupId>
<artifactId>spigot-plugin-framework</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>The damage, death, chat, effect, sidebar, tablist, team, hologram, billboard, NPC, resource pack, item, and window systems each ship their own managers and listeners as framework-owned singletons. They are not active by default: the dependency injector only constructs components in packages it has been told to scan.
Declare the packages you want with @Scan on your @Application class, or on any interface or superclass in its hierarchy. The ScanResolver walks the full type graph of the bootstrap class and collects every @Scan it finds, so each layer can declare what it owns.
@Application
@Scan("io.github.trae.spigot.framework.window")
public class CorePlugin extends SpigotPlugin {
@Override
public void onEnable() {
this.initializePlugin();
}
@Override
public void onDisable() {
this.shutdownPlugin();
}
}@Application
@Scan({
"io.github.trae.spigot.framework.item",
"io.github.trae.spigot.framework.window",
"io.github.trae.spigot.framework.sidebar"
})
public class CorePlugin extends SpigotPlugin {
}Scanning the framework root picks up every subsystem, since a base package is treated as a prefix and all subpackages are included:
@Application
@Scan("io.github.trae.spigot.framework")
public class CorePlugin extends SpigotPlugin {
}| Package | Provides |
|---|---|
io.github.trae.spigot.framework.item |
ItemManager, ItemApplyListener, ItemActivateListener, ItemPreventionListener |
io.github.trae.spigot.framework.window |
WindowManager, WindowListener |
io.github.trae.spigot.framework.sidebar |
SidebarManager, SidebarListener |
io.github.trae.spigot.framework.tablist |
TablistManager, TablistListener |
io.github.trae.spigot.framework.team |
TeamManager, TeamListener |
io.github.trae.spigot.framework.hologram |
HologramManager, HologramListener |
io.github.trae.spigot.framework.billboard |
BillboardManager, BillboardListener |
io.github.trae.spigot.framework.npc |
NpcManager, NpcInteractListener, NpcDamageListener, NpcDeathListener, NpcStaleListener |
io.github.trae.spigot.framework.resourcepack |
ResourcePackManager, ResourcePackListener |
io.github.trae.spigot.framework.damage |
DamageManager, DamageListener, CustomDamageListener, and the reduction, durability, knockback, critical, potion effect, delay, interval and attack-speed listeners |
io.github.trae.spigot.framework.death |
DeathListener, DeathMessageListener |
io.github.trae.spigot.framework.chat |
ChatManager, PreChatListener, CustomChatListener |
io.github.trae.spigot.framework.effect |
EffectManager, EffectListener |
io.github.trae.spigot.framework.blocking |
SwordBlockListener |
Your own @Application class's package is always scanned, so the sidebars, items, windows, and teams you define alongside it are discovered without any extra declaration. @Scan is only for pulling in packages you do not own.
Components discovered through @Scan are system-scoped: they are registered once by the first application whose hierarchy resolves the package, shared across every application after that, and torn down only when the last application shuts down. Two plugins scanning the same package therefore share one manager instance rather than each getting their own.
Extend SpigotPlugin to get automatic listener, command, and subcommand registration:
@Application
public class CorePlugin extends SpigotPlugin {
@Override
public void onEnable() {
this.initializePlugin();
}
@Override
public void onDisable() {
this.shutdownPlugin();
}
}Extend BaseCommand with the appropriate sender type. The second type parameter names the owning Manager, which the command resolves through getParent(). Permission is passed via the constructor:
@Singleton
public class AccountCommand extends BaseCommand<CorePlugin, AccountManager, CommandSender> {
public AccountCommand() {
super("account", "Account management", List.of("acc", "client"), "core.commands.account");
}
@Override
public void execute(final CommandSender sender, final String[] args) {
sender.sendMessage("Account command executed!");
}
@Override
public List<String> getTabComplete(final CommandSender sender, final String[] args) {
return Collections.emptyList();
}
}The second type parameter names the parent command. Subcommands are attached to that parent automatically as each component is initialized:
@Singleton
public class AdminSubCommand extends BaseSubCommand<CorePlugin, AccountCommand, Player> {
public AdminSubCommand() {
super("admin", "Toggle Admin Mode", Collections.emptyList(), "core.commands.account.admin");
}
@Override
public void execute(final Player player, final String[] args) {
this.getParent().getParent().getAccountByPlayer(player).ifPresent(account -> {
if (account.isAdministrating()) {
account.setAdministrating(false);
UtilMessage.message(player, "Account", UtilString.pair("Admin Mode", "<red>Disabled</red>"));
} else {
account.setAdministrating(true);
UtilMessage.message(player, "Account", UtilString.pair("Admin Mode", "<green>Enabled</green>"));
}
});
}
@Override
public List<String> getTabComplete(final Player player, final String[] args) {
return Collections.emptyList();
}
}This registers /account admin automatically: the parent AccountCommand routes the admin argument to AdminSubCommand with the remaining args.
/account admin
│
├─ Sender type validation (Player)
├─ Permission check (core.commands.account.admin)
├─ CommandExecuteEvent (cancellable)
└─ AdminSubCommand.execute(player, new String[0])
Use UtilEvent for thread-safe event dispatch:
// Synchronous, fire and inspect
MyEvent event = UtilEvent.supply(new MyEvent());
if (event.isCancelled()) {
return;
}
// Asynchronous, fire and forget
UtilEvent.dispatchAsynchronous(new MyAsyncEvent());
// Asynchronous, fire and chain
UtilEvent.supplyAsynchronous(new MyAsyncEvent()).thenAccept(event -> System.out.println("Done: " + event.isCancelled()));Use UtilTask for scheduling across Bukkit's threading model:
// Execute on the main server thread
UtilTask.executeSynchronous(() -> {
player.teleport(spawn);
});
// Execute asynchronously off the main thread
UtilTask.executeAsynchronous(() -> {
// Heavy computation or I/O
});
// Repeating task on the main thread with cancellation
UtilTask.schedule(() -> {
player.sendMessage("Tick!");
}, 0, 1, ChronoUnit.SECONDS, () -> !player.isOnline());
// Repeating async task
UtilTask.scheduleAsynchronous(() -> {
// Periodic background work
}, 0, 5, ChronoUnit.SECONDS);Use UtilMessage for MiniMessage-formatted messaging with configurable prefixes:
// Prefixed message to a player
UtilMessage.message(player, "Factions", "You joined <aqua>Faction %s</aqua>.".formatted(faction.getName()));
// Prefixed message with MiniMessage tags
UtilMessage.message(player, "Shop", "<gold>+50 coins</gold> from daily reward!");
// Message a Collection of Players with Predicate and Ignored
UtilMessage.message(playerList, "Punish", "<yellow>%s</yellow> has banned <yellow>%s</yellow> for <light_purple>%s</light_purple>.".formatted(sender.getName(), target.getName(), duration), player -> player.isOp(), Collections.singletonList(target.getUniqueId()));
// Broadcast to all online players
UtilMessage.broadcast("Server", "<red><bold>Restarting</bold></red> in <yellow>5 minutes</yellow>.");
// Broadcast with ignore list
UtilMessage.broadcast("Alert", "<red>PvP is now enabled!</red>", List.of(excludedPlayerUUID));
// Log to console
UtilMessage.log("Core", "Plugin loaded successfully!");The framework provides a declarative item system. An item describes what a stack should look like, and the framework turns that description into an ItemStack and keeps existing stacks in line with it.
Item is the plain description, producing stacks with no identity, suited to transient things such as window icons. CustomItem stamps an identifier and a version hash onto every stack it produces, so the stack can be recognised later and brought back in line when the definition changes. ActivatableCustomItem and its subclasses add click behaviour on top.
A stack the framework does not recognise still passes through, under a DefaultItem for its material. Nothing is written to it, but the update events fire, so a listener can apply something uniformly across every stack rather than only the custom ones.
Requires @Scan("io.github.trae.spigot.framework.item").
Extend Item when the stack is throwaway and never needs to be recognised again:
public class BackIcon extends Item {
public BackIcon() {
super(Material.RED_WOOL);
}
@Override
public Color getColor() {
return ChatColor.RED.getColor();
}
@Override
public String getName() {
return "Back";
}
@Override
public List<String> getLore() {
return List.of("Return to the previous window.");
}
}final ItemStack itemStack = new BackIcon().create();Extend CustomItem and register it as a component. ItemManager discovers every subclass through the dependency injector on first use and registers it under its identifier:
@Singleton
public class MinersPickaxe extends CustomItem {
public MinersPickaxe() {
super(Material.IRON_PICKAXE, "2f9c1e04-7a13-4f60-9d2b-5c81ab3e7f10", "MINERS_PICKAXE");
}
@Override
public String getName() {
return "Miner's Pickaxe";
}
@Override
public List<String> getLore() {
return List.of(
"Mines a little faster than it should.",
"",
"Right-Click to toggle vein mining."
);
}
@Override
public NamespacedKey getModel() {
return new NamespacedKey("custom", "miners_pickaxe");
}
}The two arguments after the material do different jobs, and only one of them is ever written to a stack.
| Value | Purpose |
|---|---|
| Identifier | Opaque and permanent. Stamped onto every stack, so the item can be renamed, restyled or moved between packages without orphaning stacks already in circulation. |
| Namespace | The readable key. Never stamped, used for commands, configuration and search. |
A UUID makes a good identifier precisely because it means nothing: there is no temptation to change it when the item's name changes. Renaming the namespace costs nothing, since no stack refers to it.
// Single undamaged stack
final ItemStack single = minersPickaxe.create();
// Five of them
final ItemStack five = minersPickaxe.create(5);
// With a damage value
final ItemStack damaged = minersPickaxe.create(1, 250);
// Taking amount and durability from an existing stack
final ItemStack converted = minersPickaxe.create(existingItemStack);
// A display-only stack carrying no identity, for a menu icon
final ItemStack icon = minersPickaxe.createView();createView skips the identity stamp, so the stack has no identifier and no version and ItemManager will never recognise it. Use it anywhere a player looks at an item rather than owns it: a stack from create is indistinguishable from a real item were it ever to escape into an inventory, where a view stack plainly is not one.
Override editMeta(ItemMeta) for anything the declarative description does not cover. Cast the meta to the type the material actually produces and set what you need:
@Override
protected void editMeta(final ItemMeta itemMeta) {
if (itemMeta instanceof final LeatherArmorMeta leatherArmorMeta) {
leatherArmorMeta.setColor(org.bukkit.Color.fromRGB(0x228B22));
}
}It runs after the display options, so an option set here overrides the equivalent one, and an item writing a display name in both places keeps the one written here.
Note:
editMetaexists separately fromstamp(ItemMeta)becauseCustomItemmarks that method final to write its identifier and version, leaving subclasses no other way to reach the meta. Anything set here is also invisible to the version hash, so fold the state behind it intogenerateVersionEntries()if existing stacks should be reconciled when it changes.
An ItemStyle carries a colour, a set of decorations, a tooltip style key and a tag glyph, so an item makes one decision rather than four:
@Override
protected ItemStyle getStyle() {
return ItemQuality.LEGENDARY;
}The style supplies the display name colour and decorations, sets the tooltip frame, and appends its tag beneath the item's lore, separated by a blank line when there is lore above it. An item declaring one writes no colour, no decorations, no tooltip style and no tag line of its own.
The framework attaches no meaning to a style beyond those values. Grouping them into rarities, tiers or anything else is a decision for the plugin that defines them:
@UtilityClass
public class ItemQuality {
public static final ItemStyle LEGENDARY = ItemStyle.of(
"Legendary", // Name
ChatColor.GOLD.getColor(), // Color
new NamespacedKey("custom", "legendary"), // Tooltip Style
"\uE005" // Tag
);
}An item declaring naturallyObtainable() is registered under its material as well as its identifier. Any vanilla stack of that material a player mines, crafts, smelts, or picks up is converted into the custom item automatically:
@Singleton
public class RawIron extends CustomItem {
public RawIron() {
super(Material.RAW_IRON, "9b3e6f12-4c85-4d27-a1f0-5e8d2c7b6a49", "RAW_IRON");
}
@Override
public Color getColor() {
return ChatColor.WHITE.getColor();
}
@Override
public String getName() {
return "Raw Iron";
}
@Override
public List<String> getLore() {
return List.of("Smelt in a furnace to refine.");
}
@Override
protected boolean naturallyObtainable() {
return true;
}
}Only one item may claim a given material. If two do, only one of them is registered under it, and which one is not defined, so declare it on a single item.
Extend ActivatableCustomItem for an item that does something when clicked. ItemActivateListener resolves the item behind the clicked stack and calls onActivate once the click has survived the cancellable ItemPreActivateEvent and passed the item's own canActivate:
@Singleton
public class MinersPickaxe extends ActivatableCustomItem {
public MinersPickaxe() {
super(Material.IRON_PICKAXE, "2f9c1e04-7a13-4f60-9d2b-5c81ab3e7f10", "MINERS_PICKAXE");
}
@Override
public String getName() {
return "Miner's Pickaxe";
}
@Override
public List<String> getLore() {
return List.of("Mines a little faster than it should.");
}
@Override
public void onActivate(final Player player, final ItemStack itemStack, final ActivateType activateType) {
UtilMessage.message(player, "Items", "Vein mining <green>enabled</green>.");
}
}An item extending CustomItem directly is never invoked, so the capability is opt-in per item rather than a hook every custom item overrides.
Most items respond to one kind of click and branch on nothing. SingleActivatableCustomItem fixes the click type at construction and drops the ActivateType parameter from every hook that has an overload without it:
@Singleton
public class WarpStone extends SingleActivatableCustomItem {
public WarpStone() {
super(Material.AMETHYST_SHARD, "7e51c3b8-2d94-4a06-b83f-1ac6d095e274", "WARP_STONE", ActivateType.RIGHT_CLICK);
}
@Override
public String getName() {
return "Warp Stone";
}
@Override
public List<String> getLore() {
return List.of("Warm to the touch, and always pointing home.");
}
@Override
public boolean canActivate(final Player player, final ItemStack itemStack) {
return !player.isInsideVehicle();
}
@Override
public void onActivate(final Player player, final ItemStack itemStack) {
player.teleport(player.getWorld().getSpawnLocation());
}
@Override
public String getCooldownName() {
return "Warp";
}
@Override
public long getCooldownDuration() {
return TimeUnit.MINUTES.toMillis(5);
}
}The cooldown hooks lose their ActivateType parameter too, since the click type is already fixed.
Any other click type is refused before the item's own checks run, so a left click never reaches onActivate and the item writes no click-type check. Every hook with a click-type overload is final, so a subclass cannot accidentally override the wrong one. activateOnItemUse and activateOnBlockUse have no such overload and still take the click type.
ChannelCustomItem is for an item that does something continuously while a player holds right click, rather than once when they press it:
@Singleton
public class DiviningRod extends ChannelCustomItem {
public DiviningRod() {
super(Material.BRUSH, "0d4f8a21-6b3c-4e79-8f15-c2a70b9e4d33", "DIVINING_ROD");
}
@Override
public String getName() {
return "Divining Rod";
}
@Override
public List<String> getLore() {
return List.of("It only has opinions underground.");
}
@Override
public boolean canChannel(final Player player, final ItemStack itemStack) {
return player.getLocation().getBlockY() < 40;
}
@Override
public void onStart(final Player player, final ItemStack itemStack) {
UtilMessage.message(player, "Items", "The rod begins to twitch.");
}
@Override
public void onStop(final Player player, final ItemStack itemStack) {
UtilMessage.message(player, "Items", "The rod falls still.");
}
@Override
public void onChannel(final Player player, final ItemStack itemStack) {
player.getWorld().spawnParticle(Particle.WAX_OFF, player.getLocation().add(0, 1, 0), 4, 0.2, 0.2, 0.2, 0.01);
}
}The right click starts a channel and a scheduler ticks it from there. onChannel runs every tick until the player lets go, swaps items, logs out, an ItemChannelEvent is cancelled, or canChannel stops returning true. Whichever ends it, onStop fires exactly once, except for a logout, where there is no player left to act on.
Note canChannel is checked every tick rather than only at the start, so this rod stops on its own the moment the player climbs above ground.
| Hook | When |
|---|---|
onStart |
Once, when the channel begins |
onChannel |
Every tick the channel runs |
onStop |
Once, however the channel ended, except a logout |
canChannel |
Every tick, before onChannel |
Holding right click requires the item to have a use action. Many materials have none, a sword and a stick among them, which is why the rod above is built on a brush, and the hold never registers for those. See Sword Blocking for the component that gives one to a sword.
ActivateType groups the vanilla actions that mean the same thing to an item, so an implementation reacts to a left click without caring whether the player was aiming at a block or at air:
| Type | Covers |
|---|---|
LEFT_CLICK |
LEFT_CLICK_AIR, LEFT_CLICK_BLOCK |
RIGHT_CLICK |
RIGHT_CLICK_AIR, RIGHT_CLICK_BLOCK |
Actions with no matching type, such as physical pressure plate triggers, activate nothing. Only the main hand is handled, since the interaction event fires once per hand and an item would otherwise activate twice.
The client sends an arm swing in two places where no left click was meant: after a right click the server denied, and when a stack is dropped. The server reads both as LEFT_CLICK_AIR. A left click landing in the same or the next tick as a right click, or as a drop of a stack the framework recognises, is discarded, so a right click ability does not fire its left click twin and a dropped item does not activate on its way out.
Branch on the type when an item does different things per click:
@Override
public void onActivate(final Player player, final ItemStack itemStack, final ActivateType activateType) {
switch (activateType) {
case LEFT_CLICK -> this.cycleMode(player);
case RIGHT_CLICK -> this.fire(player, itemStack);
}
}canActivate is the item-level check, evaluated after the pre-activate event, for conditions the item itself owns such as a resource or a durability threshold. Cooldowns have their own hooks, covered below:
@Override
public boolean canActivate(final Player player, final ItemStack itemStack, final ActivateType activateType) {
if (activateType != ActivateType.RIGHT_CLICK) {
return false;
}
if (player.isInWater() || player.isInLava()) {
UtilMessage.message(player, "Item", "You cannot use <green>%s</green> while in liquid.".formatted(this.getName()));
return false;
}
return true;
}ItemPreActivateEvent is the system-level equivalent, for conditions external to the item such as a region restriction or a global lockdown:
@EventHandler
public void onItemPreActivate(final ItemPreActivateEvent event) {
if (this.regionManager.isInSafezone(event.getPlayer())) {
event.setCancelledWithReason("You cannot use items here.");
}
}ItemPostActivateEvent fires after a successful activation, for recording a statistic or logging. It is not cancellable, and never fires for an activation that was refused.
An item declares its own cooldown rather than each caller managing one:
@Override
public String getCooldownName(final ActivateType activateType) {
return this.getName();
}
@Override
public long getCooldownDuration(final ActivateType activateType) {
return TimeUnit.SECONDS.toMillis(5);
}The name is the key rather than the item, so two items returning the same name share a cooldown, and one item returning different names per click type gates each independently. getCooldownName defaults to the item's raw getName() and getCooldownDuration to zero, meaning no cooldown.
A SingleActivatableCustomItem gets the parameterless overloads, since the click type is already fixed.
A channel has the same split, checked every tick rather than once. canChannel is the item's own condition and ItemChannelEvent is the system-level one:
@EventHandler
public void onItemChannel(final ItemChannelEvent event) {
if (this.regionManager.isInSafezone(event.getPlayer())) {
event.setCancelled(true);
}
}Cancelling ends the channel outright rather than pausing it: onChannel does not run for that tick, onStop fires, and the player is dropped from the item's active set. The event is evaluated first, so canChannel never runs in a context the server has already refused.
A right click can mean something to the world as well as to the item. Two defaults decide who wins, and both are chosen so a custom item behaves the way a player expects without writing anything:
| Situation | Default | Example |
|---|---|---|
| The material has a use of its own | The item activates, and the material's use is denied | A custom ender pearl fires its ability and is not thrown |
| The clicked block responds on its own | The block wins, and the item does not activate | A chest opens, a lever toggles, a hoe tills dirt |
| Neither | The item activates | A custom sword clicked at air or stone |
A block counts as responding when it reacts to any click, such as a chest or a door, or when it reacts to the item being held, such as dirt under a hoe. A sneaking player is exempt from the block check, since vanilla skips the block's response when sneaking with a full hand. Left clicks compete with nothing and are never gated here.
Each side has a hook to flip its default:
// Let the material win, so a custom golden apple is eaten rather than activated
@Override
public boolean activateOnItemUse(final Player player, final ItemStack itemStack, final ActivateType activateType) {
return false;
}
// Activate even when the clicked block would respond, such as a wand used on a chest
@Override
public boolean activateOnBlockUse(final Player player, final ItemStack itemStack, final Block block, final ActivateType activateType) {
return true;
}activateOnItemUse defaults to true and activateOnBlockUse to false. The material and block classification behind both lives in UtilMaterial.
Once an activation is going ahead, two more hooks decide what vanilla still does alongside it. Return Event.Result.DENY from either to suppress that:
// Stop the material's own use, such as a right-clickable food item being eaten
@Override
public Event.Result useItemInHand(final Player player, final ItemStack itemStack, final ActivateType activateType) {
return Event.Result.DENY;
}
// Stop the clicked block responding, such as a chest opening
@Override
public Event.Result useInteractedBlock(final Player player, final ItemStack itemStack, final Block block, final ActivateType activateType) {
return Event.Result.DENY;
}useItemInHand denies a right click on a material that has a use of its own, which is what keeps the pearl in hand, and returns Event.Result.DEFAULT otherwise. Return DEFAULT from it for an item whose material should still be used, such as a pearl that fires an ability and is thrown as well. useInteractedBlock defaults to Event.Result.DEFAULT, leaving the block untouched, and is only consulted when a block was clicked. The block is passed so the decision can depend on what was clicked:
@Override
public Event.Result useInteractedBlock(final Player player, final ItemStack itemStack, final Block block, final ActivateType activateType) {
return block.getType() == Material.CHEST ? Event.Result.DENY : Event.Result.DEFAULT;
}A custom item's tooltip is authored rather than generated, so an enchantment or a player-set name would not survive the next reconciliation. ItemPreventionListener refuses both outright:
| Station | Behaviour |
|---|---|
| Anvil | The output is cleared when either input slot holds a custom item, covering renaming, repairing and combining alike |
| Enchanting table | No offers are shown, and the enchantment itself is refused, when the item or the lapis slot holds a custom item |
The enchanting check runs twice on purpose. An offer suppressed at preparation can be restored by another plugin, and the slot can change between the offer being shown and the button being pressed, so the same check is repeated at the point the enchantment would be applied.
A custom item is refused as a reagent too, not only as the thing being worked on, so a custom item built on lapis or on an enchanted book is never consumed by a station.
Every stack a CustomItem produces carries a SHA-256 hash of the item's full description. Change the display name, lore, model, colour, or any other described property, and the hash changes, which marks every stack already in circulation as outdated.
ItemManager#apply(ItemStack) reads the stamped identifier, finds the owning item, and updates the stack when its version no longer matches. Amount and durability are preserved, so a pickaxe a player has been using for weeks keeps its damage while gaining the new lore.
Reconciliation runs automatically at every point a stack enters a player's possession:
| Trigger | Handled By |
|---|---|
| Item pickup | EntityPickupItemEvent |
| Crafting result preview and craft | PrepareItemCraftEvent |
| Furnace smelt result | FurnaceSmeltEvent |
| Player join | PlayerJoinEvent |
| Opening a chest, double chest, barrel, shulker or other block-backed container | InventoryOpenEvent |
| Every 30 seconds, all online inventories | Scheduler |
The scheduler covers the remaining case: a stack sitting untouched in a player's inventory when an item's definition changes at runtime. Containers are reconciled as they are opened rather than on a timer, and crafting grids, anvils and the framework's own windows are left out, since their contents are transient or managed elsewhere.
A stack still carrying an identifier or version from an item that is no longer registered is an orphan. By default it is rebuilt as a plain stack of its material, with the stale data stripped. An item built on a material that is meaningless without it can ask for its stacks to be deleted instead:
@Override
protected boolean deleteIfRemoved() {
return true;
}The answer is stamped onto every stack rather than read from the item at the time, since by then there is no item left to ask. It is part of the version hash, so flipping it marks existing stacks outdated and they pick up the new answer on their next update, and the mark is cleared rather than left behind when an item stops asking for it.
Override generateVersionEntries() to fold subclass state into the hash, so a change to that state also marks existing stacks outdated:
@Override
protected List<String> generateVersionEntries() {
return Stream.concat(
super.generateVersionEntries().stream(),
Stream.of(UtilString.pair("Reward-Amount", Integer.toString(this.rewardConfig.getAmount())))
).toList();
}// Reconcile a single stack
final ItemStack reconciled = this.itemManager.apply(itemStack);
// Reconcile a whole inventory
this.itemManager.updateInventory(player.getInventory());
// Look an item up by identifier
this.itemManager.getItemByIdentifier(identifier).ifPresent(item -> player.getInventory().addItem(item.create()));
// Resolve one from a name a player typed, matching on namespace
this.itemManager.searchItem(sender, input, true).ifPresent(item -> player.getInventory().addItem(item.create()));apply takes one of these routes:
| Stack | Route |
|---|---|
| Known identifier, outdated version | update, rewriting the description in place, or rebuilding the stack if the item's material changed |
| Known identifier, current version | refresh, dispatching the events and nothing else |
| No identifier, obtainable material | create, building a fresh stack |
| Orphan marked deletable | null, removing the stack |
| Orphan otherwise | create under its DefaultItem, building a clean stack |
| Anything else | refresh under its DefaultItem |
Three routes replace a stack outright. The update route does so only when the item's material has changed, since the stack has to be rebuilt as the new material. The obtainable route reinterprets the material as a custom item, and the orphan route strips data that belongs to an item that no longer exists, so enchantments and other data do not carry across either of those two. Either an identifier or a version alone is enough to count as an orphan, since a half-stamped stack is as stale as a fully stamped one.
A null return means the stack should be removed, and every caller carries that out in whatever way its own context allows. updateInventory clears the slot. Every other route returns the input by reference, so an identity comparison tells a caller whether the stack was replaced rather than merely altered.
The refresh route exists so a listener still runs against a stack that needed no rewriting. An item's version hash covers its own description and knows nothing about what a listener adds on top, so version-gating alone would leave those stacks permanently missing it.
The framework provides an inventory window system. A Window owns its own inventory and is composed of Buttons bound to slots. Clicks are dispatched to the button in the clicked slot, and every click and drag on a window inventory is cancelled, so nothing can be moved into or out of one.
Requires @Scan("io.github.trae.spigot.framework.window").
Extend Window, passing the title and row count, and register buttons in populate:
public class ProfileWindow extends Window {
private final AccountManager accountManager;
public ProfileWindow(final AccountManager accountManager) {
super(Component.text("Profile", NamedTextColor.GOLD), 3);
this.accountManager = accountManager;
}
@Override
protected void populate(final Player player) {
this.addButton(new StatsButton(this, 11, player));
this.addButton(new SettingsButton(this, 15));
}
}Extend Button, typed with the window it belongs to, and pass that window, the slot, and the stack it renders:
public class SettingsButton extends Button<ProfileWindow> {
public SettingsButton(final ProfileWindow window, final int slot) {
super(window, slot, new SettingsIcon().createView());
}
@Override
protected List<String> getLore() {
return List.of("<green>Click to open settings.");
}
@Override
public boolean canClick(final Player player, final ClickType clickType) {
return clickType.isLeftClick();
}
@Override
public void onClick(final Player player, final ClickType clickType) {
UtilWindow.open(player, new SettingsWindow(this.getWindow()));
}
}The window is typed, so a button reaches its window's own state and methods without a cast: paging, toggling a filter, or triggering a re-render.
getName and getLore layer over the base stack rather than replacing it. The base is cloned first, so a button handed a shared stack never mutates it; a display name the button declares overrides the base's own, and lore is appended beneath the base's, separated by a blank line. That lets a button annotate an item with what clicking it does while leaving the item's own description intact.
Use createView() rather than create() for the base stack. A menu icon has no business carrying an item's identity, and a view stack cannot be mistaken for the real thing were it ever to escape the window.
Open through UtilWindow rather than openInventory directly, so the open event and canOpen gate are honoured and the window renders for the player being opened for:
UtilWindow.open(player, new ProfileWindow(this.accountManager));A window owns one inventory, created in its constructor and reused for its whole lifetime. Scope therefore follows instance lifetime:
| Usage | Result |
|---|---|
new SomeWindow(...) per open |
Private to that player |
Held as a @Singleton or a field |
Shared by everyone who opens it |
Constructing per open is the normal case, and is what keeps one player's window contents from being visible to another.
| Method | Effect |
|---|---|
render(Player) |
Clears the buttons, re-runs populate, and redraws. Use when the button set itself may have changed. |
refresh() |
Redraws the existing buttons without re-running populate. Use when only their rendered stacks changed. |
Because the inventory is reused, calling either on a window someone is currently viewing updates it in place without closing it. This is what makes paging and toggles work:
@Override
public void onClick(final Player player, final ClickType clickType) {
this.window.setPage(this.window.getPage() + 1);
this.window.render(player);
}| Hook | Effect |
|---|---|
canOpen(Player) |
Returning false aborts the open, leaving whatever the player has open in place |
canClose(Player) |
Returning false re-opens the inventory a tick later, holding the player in the window |
onOpen(Player) |
Called after the window has been shown |
onClose(Player) |
Called after the player closed it and tracking entries were dropped |
Button#canClick(Player, ClickType) |
Returning false suppresses the button's action |
canOpen, canClose, and canClick are the window-level checks, for conditions the window or button itself owns. WindowOpenEvent, WindowCloseEvent, and ButtonPreClickEvent are the system-level equivalents, for conditions external to it, such as a world restriction or a global lockdown.
A click passes ButtonPreClickEvent first, then canClick, and fires ButtonPostClickEvent once the action has run. The framework throttles nothing itself, so rate-limiting belongs in a ButtonPreClickEvent listener.
A sub-window holds its parent, and a back button opens it again. The parent still owns its inventory, so returning to it is just another open:
public class SettingsWindow extends Window {
private final Window parentWindow;
public SettingsWindow(final Window parentWindow) {
super(Component.text("Settings"), 3);
this.parentWindow = parentWindow;
}
@Override
protected void populate(final Player player) {
this.addButton(new BackButton(22, this.parentWindow));
}
}BackButton is provided by the framework in io.github.trae.spigot.framework.window.types.buttons, with a default icon or one you supply.
// What is this player looking at
this.windowManager.getWindowByPlayer(player).ifPresent(window -> window.refresh());
// Which window owns this inventory
this.windowManager.getWindowByInventory(inventory).ifPresent(window -> window.refresh());Click dispatch never consults these maps. A window is its own InventoryHolder, so a click resolves straight off the event and a momentarily stale map can never misroute one.
A sword has no use action of its own, so right-clicking one does nothing and isBlocking can never become true for it. SwordBlockListener attaches the blocks_attacks data component to every sword the item system produces, which gives it one.
Requires @Scan("io.github.trae.spigot.framework.blocking").
That matters for two reasons. It is what lets a ChannelCustomItem on a sword material register the hold at all, and it drives the minecraft:using_item model condition a resource pack needs to swap in a blocking pose.
The component is applied with no damage reductions, so blocking is a pure gesture that reduces nothing, and with a disable cooldown scale of zero, so an axe hit cannot interrupt it. The block delay is one tick rather than the shield's quarter second, so the raise registers immediately.
It hooks ItemStackUpdateEvent, which fires on every route through apply, so custom swords, vanilla ones passing through their default definition, and swords already current by version are all covered.
Note: the arm animation is the shield's, not 1.8's. A resource pack can change how the sword sits in the hand while blocking, but not how the arm moves.
The framework provides a packet-based sidebar (scoreboard) system with priority-based resolution. Multiple Sidebar subclasses can be registered, and the lowest priority one that passes all display checks is shown. Only changed lines and titles produce packets, eliminating flicker.
Requires @Scan("io.github.trae.spigot.framework.sidebar").
Extend Sidebar, passing an identifier and a priority, and register it as a component. SidebarManager discovers every subclass automatically through the dependency injector:
@Singleton
public class HubSidebar extends Sidebar {
private final PlayerManager playerManager;
public HubSidebar(final PlayerManager playerManager) {
super("HUB", 10);
this.playerManager = playerManager;
}
@Override
protected Component getTitle(final Player player) {
return Component.text("MY SERVER", NamedTextColor.GOLD, TextDecoration.BOLD);
}
@Override
protected List<Component> getLines(final Player player) {
final PlayerData data = this.playerManager.getPlayerData(player);
return List.of(
Component.text("Online: ", NamedTextColor.GRAY).append(Component.text(Bukkit.getOnlinePlayers().size(), NamedTextColor.WHITE)),
Component.text("Rank: ", NamedTextColor.GRAY).append(Component.text(data.getRank(), NamedTextColor.GOLD)),
Component.text("Coins: ", NamedTextColor.GRAY).append(Component.text(data.getCoins(), NamedTextColor.YELLOW))
);
}
}Note: the manager's scheduler runs asynchronously, so
getTitleandgetLinesmay be called off the main thread. Build lines from cached or thread-safe state rather than reading live world or entity data inside them.
Override isStaticTitle() to enable per-tick title updates driven by the manager's scheduler:
private int tick = 0;
private static final List<TextColor> COLORS = List.of(
NamedTextColor.RED, NamedTextColor.GOLD, NamedTextColor.YELLOW,
NamedTextColor.GREEN, NamedTextColor.AQUA, NamedTextColor.LIGHT_PURPLE
);
@Override
protected boolean isStaticTitle() {
return false;
}
@Override
protected Component getTitle(final Player player) {
return Component.text("MY SERVER", COLORS.get(this.tick++ % COLORS.size()), TextDecoration.BOLD);
}Lower priority always wins. When the lowest-numbered sidebar becomes ineligible, the next one takes over automatically:
@Singleton
public class FactionsSidebar extends Sidebar {
private final FactionsManager factionsManager;
public FactionsSidebar(final FactionsManager factionsManager) {
super("FACTIONS", 0); // wins over HubSidebar at 10
this.factionsManager = factionsManager;
}
@Override
protected boolean canDisplay(final Player player) {
return this.factionsManager.isInFaction(player);
}
@Override
protected Component getTitle(final Player player) {
return Component.text("FACTIONS", NamedTextColor.RED, TextDecoration.BOLD);
}
@Override
protected List<Component> getLines(final Player player) {
// faction-specific lines
}
}canDisplay() with no arguments is the global gate, for state independent of any player. Both it and the per-player variant must pass for a sidebar to be eligible.
Fire SidebarUpdateEvent to trigger a refresh for a player:
// Update whatever sidebar is currently active
UtilEvent.dispatch(new SidebarUpdateEvent(player));
// Only update if the active sidebar matches the given identifier
UtilEvent.dispatch(new SidebarUpdateEvent("HUB", player));Cancelling the event clears the player's sidebar instead of refreshing it.
The scheduler runs four times a second, but nothing is sent unless the diff finds a change, so a static sidebar costs resolution time and no bandwidth at all. What the interval does cost is a getTitle and getLines call per player per pass, so keep those cheap and built from cached state rather than live lookups.
The registered sidebars are sorted by priority once on first use rather than on every lookup, so resolving a player's sidebar is a walk of an already-ordered list.
The tablist system resolves a player's tab list header and footer the same way the sidebar system resolves a sidebar: the lowest priority Tablist passing all display checks wins. An asynchronous scheduler re-resolves and re-sends on a fixed interval, so dynamic content stays current without any manual dispatch.
Requires @Scan("io.github.trae.spigot.framework.tablist").
Extend Tablist, passing a priority. There is no identifier, since only one tablist applies at a time and updates are never scoped:
@Singleton
public class HubTablist extends Tablist {
public HubTablist() {
super(10);
}
@Override
protected Component getHeader(final Player player) {
return Component.text("MY SERVER", NamedTextColor.GOLD, TextDecoration.BOLD);
}
@Override
protected Component getFooter(final Player player) {
return Component.text("play.myserver.net", NamedTextColor.GRAY);
}
}Same rules as the sidebar system: lower wins, and both canDisplay() and canDisplay(Player) must pass:
@Singleton
public class EventTablist extends Tablist {
private final EventManager eventManager;
public EventTablist(final EventManager eventManager) {
super(0); // wins over HubTablist at 10
this.eventManager = eventManager;
}
@Override
protected boolean canDisplay() {
return this.eventManager.isEventRunning();
}
@Override
protected Component getHeader(final Player player) {
return Component.text("EVENT LIVE", NamedTextColor.RED, TextDecoration.BOLD);
}
@Override
protected Component getFooter(final Player player) {
return Component.text("Type /event to join", NamedTextColor.YELLOW);
}
}The scheduler dispatches TablistUpdateEvent for every online player on a fixed interval, so there is normally nothing to fire yourself. Cancelling the event clears that player's tablist, which is how another system suppresses the display for them:
@EventHandler
public void onTablistUpdate(final TablistUpdateEvent event) {
if (this.settingsManager.hasTablistHidden(event.getPlayer())) {
event.setCancelled(true);
}
}The clearing packet is only sent once, on the transition away from an active tablist, rather than every tick.
Unlike the sidebar, nothing is cached to diff against, so every dispatch is a send. That is why the interval is a second rather than a tick: at a high player count, a faster interval is bandwidth spent re-sending content that has not changed.
The registered tablists are sorted by priority once on first use, same as the sidebar.
The framework provides a packet-based team system for per-viewer prefix and suffix resolution. Each online player has a team entry sent individually to every viewer, allowing relation-aware nametag colours such as faction ally versus enemy.
Requires @Scan("io.github.trae.spigot.framework.team").
Extend Team, passing an identifier and a priority, and register it as a component. Lower priority wins when multiple are eligible:
@Singleton
public class RankTeam extends Team {
private final PlayerManager playerManager;
public RankTeam(final PlayerManager playerManager) {
super("RANK", 10); // fallback
this.playerManager = playerManager;
}
@Override
protected Component getPrefix(final Player player, final Player viewer) {
final String rank = this.playerManager.getPlayerData(player).getRank();
return Component.text("[" + rank + "] ", NamedTextColor.GOLD);
}
}@Singleton
public class FactionsTeam extends Team {
private final FactionsManager factionsManager;
public FactionsTeam(final FactionsManager factionsManager) {
super("FACTIONS", 0); // wins over RankTeam
this.factionsManager = factionsManager;
}
@Override
protected boolean canDisplay(final Player player, final Player viewer) {
return this.factionsManager.isInFaction(player);
}
@Override
protected Component getPrefix(final Player player, final Player viewer) {
final FactionRelation relation = this.factionsManager.getRelation(viewer, player);
return switch (relation) {
case ALLY -> Component.text("[ALLY] ", NamedTextColor.GREEN);
case ENEMY -> Component.text("[ENEMY] ", NamedTextColor.RED);
default -> Component.text("[NEUTRAL] ", NamedTextColor.YELLOW);
};
}
}Resolution happens per player and viewer pair, which is why canDisplay and every option hook take both. That is what lets the same target player present different decorations to different viewers.
Every option hook returns null by default, leaving the underlying vanilla value in place rather than overriding it:
| Hook | Controls |
|---|---|
getDisplayName(player, viewer) |
Team display name |
getPrefix(player, viewer) |
Nametag prefix |
getSuffix(player, viewer) |
Nametag suffix |
allowFriendlyFire(player, viewer) |
Friendly fire within the team |
seeFriendlyInvisibles(player, viewer) |
Visibility of friendly invisibles |
getNameTagVisibility(player, viewer) |
Nametag visibility rule |
getDeathMessageVisibility(player, viewer) |
Death message visibility rule |
getCollisionRule(player, viewer) |
Collision rule |
getColor(player, viewer) |
Team colour, which also sets the nametag name colour |
The team system has no scheduler, since resolving every player and viewer pair on a timer is quadratic in online players. Teams refresh on join, on quit, and whenever TeamUpdateEvent is fired, so whatever changes a relation should dispatch it:
// Update this player's team for all viewers
UtilEvent.dispatch(new TeamUpdateEvent(player));
// Only apply for viewers whose eligible team matches the given identifier
UtilEvent.dispatch(new TeamUpdateEvent("factions", player));Resolution is per player and viewer pair, so a full refresh at a hundred players is ten thousand lookups. The registered teams are therefore sorted by priority once on first use rather than scanned and sorted per lookup, which is the difference between a noticeable stall and nothing.
That same quadratic shape is why firing TeamUpdateEvent in a loop is worth avoiding. One player's relation change is one dispatch, not one per viewer.
The framework provides a packet-based hologram system built on text displays. A Hologram describes where it sits and what it says, and the framework sends it to the players who should see it.
Nothing exists server-side. The backing entity is constructed but never added to a level, so the server never ticks it, no chunk persists it, and no other plugin can see it. That is what makes the text per-player: two players standing in the same place can be sent different lines from the same hologram, and a hologram can be shown to one of them and not the other.
Requires @Scan("io.github.trae.spigot.framework.hologram").
Extend Hologram, passing a name, and register it as a component. HologramManager collects every subclass through the dependency injector, so a hologram never registers itself:
@Singleton
public class SpawnHologram extends Hologram {
private final SpawnConfig spawnConfig;
public SpawnHologram(final SpawnConfig spawnConfig) {
super("SPAWN");
this.spawnConfig = spawnConfig;
}
@Override
public Location getLocation() {
return this.spawnConfig.getHologramLocation();
}
@Override
protected List<String> getLines(final Player player) {
return List.of(
"<gold><bold>WELCOME</bold></gold>",
"<gray>Right-Click the villager to begin.</gray>"
);
}
}Lines are MiniMessage strings, deserialised and joined with newlines, which is how a single text display renders more than one line.
getLines takes the player it is resolving for, so the text can name them, reflect their rank, or read their own progress. Declare isDynamic() when the text changes on its own and should be re-sent while they watch:
@Singleton
public class StatsHologram extends Hologram {
private final PlayerManager playerManager;
public StatsHologram(final PlayerManager playerManager) {
super("STATS");
this.playerManager = playerManager;
}
@Override
public Location getLocation() {
return new Location(Bukkit.getWorld("world"), 0.5, 65.0, 0.5);
}
@Override
protected List<String> getLines(final Player player) {
final PlayerData data = this.playerManager.getPlayerData(player);
return List.of(
"<yellow>%s</yellow>".formatted(player.getName()),
"<gray>Kills: <white>%s</white></gray>".formatted(data.getKills()),
"<gray>Coins: <white>%s</white></gray>".formatted(data.getCoins())
);
}
@Override
public boolean isDynamic() {
return true;
}
}Leave isDynamic() false unless the text really does change. A dynamic hologram resolves its lines, deserialises them, and sends a metadata packet for every viewer on every pass, where a static one does that once at spawn.
canSee is the hologram's own rule, checked after the world and distance tests pass:
@Override
public boolean canSee(final Player player) {
return this.questManager.hasStarted(player);
}HologramSpawnEvent is the system-level equivalent, for conditions external to the hologram such as a global lockdown or another plugin hiding it. Both must pass.
Cancelling leaves the player out of the viewer set, so the next pass tries again. A listener that wants a hologram hidden keeps cancelling for as long as that holds, rather than cancelling once:
@EventHandler
public void onHologramSpawn(final HologramSpawnEvent event) {
if (this.settingsManager.hasHologramsHidden(event.getPlayer())) {
event.setCancelled(true);
}
}Every appearance hook has a default, so a hologram overrides only what it cares about:
| Hook | Controls | Default |
|---|---|---|
getBillboard() |
How the display rotates to face viewers | CENTER |
getAlignment() |
How multiple lines align against each other | CENTER |
getBackgroundColor() |
The ARGB background behind the text | Fully transparent |
getBackground() |
A bitmap font glyph drawn behind the text, as MiniMessage | null |
getScale() |
Uniform scale | 1.0F |
getTextOpacity() |
Text opacity, -1 for fully opaque |
-1 |
getLineWidth() |
Pixel width at which text wraps | 200 |
isSeeThrough() |
Whether the text renders through blocks | false |
isShadowed() |
Whether the text is drawn with a shadow | false |
getViewDistance() |
How far a player may be and still be sent it | 48.0D |
Billboard.FIXED is the one worth knowing. It does not rotate at all, facing whatever direction the location's yaw and pitch specify, which is what gives a hologram mounted flat against a wall or laid across the floor:
@Override
protected Billboard getBillboard() {
return Billboard.FIXED;
}
@Override
protected Color getBackgroundColor() {
return Color.fromARGB(160, 0, 0, 0);
}Billboard.VERTICAL turns to face the player but stays upright, and Billboard.CENTER pivots on both axes and tilts with their pitch.
The vanilla grey box is always disabled, so getBackgroundColor is the only flat background a viewer sees, and it is only ever a rectangle. A shaped or textured background comes from getBackground, covered below.
There is no image support in a text display, and no packet that carries one. Both images and backgrounds go through a bitmap font provider in a resource pack: register a glyph mapping a private-use character to a PNG, then return that character in a line like any other text. The provider's height and ascent control how it sits against the text, and a negative-space provider backs the cursor up when something needs to sit behind rather than beside.
getBackground returns a glyph to draw behind every line, rendered as its own first line. The string must have a net advance of zero so the line stays centred: a negative space of half the glyph's rendered width, the glyph, then a negative space of half its width plus one, since a bitmap glyph advances one pixel past its width.
{
"providers": [
{"type": "space", "advances": {"\uE001": -80, "\uE002": -81}},
{"type": "bitmap", "file": "custom:font/hologram/background.png", "ascent": 7, "height": 96, "chars": ["\uE000"]}
]
}@Override
protected String getBackground() {
return "<font:custom:hologram>\uE001\uE000\uE002</font>";
}An ascent of 7 aligns the image's top with the first row, and height sets how far down it reaches behind the lines below, at roughly ten pixels per line. The rendered width is the PNG's width scaled by height over its own height, and the two advances follow from that. Keep isShadowed() false, or the image is drawn with a darkened copy behind it.
The glyph line adds one row of padding above the first line of text, so draw any padding a design wants into the PNG itself.
There is no registration call and no manual show or hide. A scheduler runs twice a second and reconciles each player's client against each hologram, spawning what has come into view, despawning what has left it, and re-sending the text of dynamic ones:
| State | Result |
|---|---|
| Visible, not currently viewing | Spawned, if HologramSpawnEvent is not cancelled |
| Not visible, currently viewing | Despawned |
| Visible, viewing, dynamic | Text re-sent |
| Visible, viewing, static | Nothing |
Visibility depends on the player's position, the hologram's position and canSee, and only the first of those produces an event, which is why the system polls rather than listening for movement. The pass is holograms outer and players inner, so a hologram resolves its location and settings once rather than once per player, and players in another world are rejected by a single comparison.
Position and rotation live in the add packet and cannot be corrected by a metadata update, so a hologram that has moved is despawned and spawned again rather than updated in place:
// After getLocation() already returns the new location
this.hologramManager.relocate(hologram);
// Push a settings change to current viewers, for a static hologram
this.hologramManager.refresh(hologram);
// Force a clean re-send by dropping every viewer
this.hologramManager.despawn(hologram);
// Look one up by name, case-insensitively
this.hologramManager.getHologramByName("SPAWN").ifPresent(hologram -> this.hologramManager.refresh(hologram));despawn is not a way to hide a hologram, since the next pass spawns it again if it is still visible. Use canSee or a cancelled HologramSpawnEvent for that.
A hologram whose getLocation() returns null, or names a world that is not loaded, is treated as not ready rather than as an error. The scheduler retries it, so a hologram in a world that loads late starts working on its own.
The framework provides a packet-based billboard system. A billboard is an image or a video shown across a grid of maps in invisible item frames, one map per 128x128 tile.
Nothing exists server-side. The frames are constructed but never added to a level, and the maps use IDs counted down from Integer.MAX_VALUE, so they never collide with a real map and nothing is written to the world. That is also what keeps bandwidth down: every packet goes to a player who can see the billboard, and an image's colours go to each player only once.
Requires @Scan("io.github.trae.spigot.framework.billboard").
Billboard is sealed, and plugins never extend it directly. They extend one of its two forms:
| Type | Shows | Supplies |
|---|---|---|
BillboardImage |
A still image | loadImage(), returning the image |
BillboardVideo |
A sequence of frames | getDirectory(), returning the folder of frames |
Both take the same constructor arguments: an identifier, the location of the top-left frame, the direction the frames face, and the grid size in columns and rows. Both are registered as a component, and BillboardManager collects every one through the dependency injector.
The location is the block the top-left frame occupies, in front of the wall. Tiles run left to right, then top to bottom, as seen by a player facing the wall. Only horizontal facings are supported. The frames are never placed in the world, so nothing needs to be behind them, though a wall keeps them from looking like they float.
Extend BillboardImage and return the image. PNG, JPG and anything else ImageIO reads all work:
@Singleton
public class PracticeBannerBillboard extends BillboardImage {
private final File file;
public PracticeBannerBillboard(final CorePlugin plugin) {
super("practice_banner", new Location(Bukkit.getWorld("lobby"), 100.0D, 80.0D, 50.0D), BlockFace.SOUTH, 4, 7);
this.file = new File(plugin.getDataFolder(), "practice.png");
}
@Override
protected BufferedImage loadImage() throws IOException {
return ImageIO.read(this.file);
}
}The image is scaled to columns * 128 by rows * 128, stretching if the aspect ratio differs, so export it at exactly that size for the cleanest result: 512 by 896 for the grid above. It is loaded once, off the main thread.
A video is a folder of frames, played in file name order. The simplest way to produce one is to let ffmpeg split a video file, at the frame rate and grid size the billboard uses:
ffmpeg -i trailer.mp4 -vf "fps=10,scale=256:256" plugins/CorePlugin/videos/trailer/%05d.pngThen extend BillboardVideo and point it at the folder:
@Singleton
public class TrailerBillboard extends BillboardVideo {
private final File directory;
public TrailerBillboard(final CorePlugin plugin) {
super("trailer", new Location(Bukkit.getWorld("lobby"), 100.0D, 80.0D, 50.0D), BlockFace.SOUTH, 2, 2);
this.directory = new File(plugin.getDataFolder(), "videos/trailer");
}
@Override
protected File getDirectory() {
return this.directory;
}
}Every PNG and JPG in the folder is a frame, so ffmpeg's numbered output drops straight in. Every setting has a default:
| Hook | Controls | Default |
|---|---|---|
getFrameRate() |
How many frames play per second | 10 |
isLooping() |
Whether playback returns to the first frame after the last | true |
isAutoplay() |
Whether playback starts on its own once loaded | true |
onLoop(viewers) |
Something to do each time playback reaches the first frame, such as playing a soundtrack | Nothing |
Playback runs on one timeline shared by every player, so everyone watching sees the same frame. BillboardManager#play resumes a paused video, or restarts one that does not loop from the beginning, and BillboardManager#stop pauses it on its current frame.
Matching every pixel of every frame to map colours is the expensive part, so it happens once. On the first load, each frame is converted and compared with the one before it, keeping only the region of each tile that changed. The result is written deflated to .cache in the frame folder, and every later load reads it back.
The cache is keyed on every frame file's name, size and modification time along with the grid size, so replacing a frame or changing the grid rebuilds it on its own. Encoding runs off the main thread, and the billboard appears once it finishes.
Only the changed regions are held in memory, never whole frames, plus one live copy of the current frame for players who come into range mid-playback.
A player is sent a billboard when they come within getViewDistance() of its centre, 48 blocks by default, and sent its removal once they are 8 blocks past that, so a player standing on the edge does not have it sent and removed repeatedly. Viewers are reconciled twice a second.
| Billboard | Sent to a Player |
|---|---|
| Image | Its colours once per session, the first time they come into range, then only the frames on each return |
| Video | The current frame in full on coming into range, then only the regions that change, for as long as they stay in range |
The frames themselves are a few bytes each. An image's colours are re-sent only when the client has discarded its maps, on a world change or a rejoin. A video costs nothing for a player out of range, so a small grid and a tight view distance keep it cheap: a 2x2 grid is a quarter of the bandwidth of a 4x4.
A map can only show vanilla's map palette, roughly two hundred and fifty colours, so every pixel is matched to the nearest one by red-mean distance, a perceptual weighting close to how the eye judges colour. Pixels under half alpha become transparent, letting the wall show through.
Flat colours and bold outlines, the style of most server banners, convert cleanly. Smooth gradients band, since the palette has no colours between its steps.
Frames are glow item frames by default, so the image stays at full brightness at night and underground. Override isGlowing() to use plain frames, which darken with light level like any other block face.
A billboard whose world is not loaded, or whose source cannot be read, is skipped with a warning rather than failing startup.
The framework provides an NPC system backed by real server entities. An Npc describes the entity, where it stands and how it behaves, and the framework keeps it spawned.
Real entities rather than packets are what make an NPC more than a decoration: a boss paths, fights and takes damage through the same systems as any other mob, while a shopkeeper stands still and opens a window. Both are the same base class with different behaviour opted into.
Requires @Scan("io.github.trae.spigot.framework.npc").
Extend Npc with the backing entity's type, passing its class, an identifier, a namespace and a location, and register it as a component. NpcManager collects every subclass through the dependency injector:
@Singleton
public class GuideNpc extends Npc<Villager> {
public GuideNpc() {
super(Villager.class, "GUIDE", "lobby", new Location(Bukkit.getWorld("lobby"), 0.5D, 64.0D, 4.5D));
}
@Override
public Component getDisplayName() {
return Component.text("Guide", NamedTextColor.GOLD);
}
}The type parameter carries through to every hook, so onSpawn and getEntity() hand back a Villager rather than a LivingEntity to cast. The identifier is unique per NPC and is what it is looked up by, while the namespace groups NPCs by the plugin or feature that owns them.
NPCs are collected once the server has finished starting, so an NPC registered by any plugin is picked up regardless of enable order.
Every setting has a default, so an NPC overrides only what it needs:
| Hook | Controls | Default |
|---|---|---|
getDisplayName() |
The name shown above the entity, and in death messages | null |
hasAI() |
Whether the entity moves, paths and attacks | false |
isInvulnerable() |
Whether the entity ignores damage | true, or false for a DamageableNpc |
isSilent() |
Whether the entity makes no sounds | true |
isCollidable() |
Whether the entity pushes and is pushed | false |
canSpawn() |
Whether the NPC may spawn right now | true |
onSpawn(entity) |
Further setup once spawned, such as equipment or attributes | Nothing |
Implement InteractableNpc for an NPC players can right-click:
@Singleton
public class ShopkeeperNpc extends Npc<Villager> implements InteractableNpc {
public ShopkeeperNpc() {
super(Villager.class, "SHOPKEEPER", "lobby", new Location(Bukkit.getWorld("lobby"), 0.5D, 64.0D, 0.5D));
}
@Override
public Component getDisplayName() {
return Component.text("Shopkeeper", NamedTextColor.GOLD);
}
@Override
public boolean canInteract(final Player player) {
return !player.isSneaking();
}
@Override
public void onInteract(final Player player) {
UtilWindow.open(player, new ShopWindow());
}
}A click passes the cancellable NpcInteractEvent first, then the NPC's own canInteract, and only reaches onInteract if both allow it. Only the main hand is routed, so a click never fires twice.
The vanilla interaction is cancelled for every NPC, interactable or not and in either hand, so a villager never opens its trade screen and an armour stand never gives up its equipment.
Implement DamageableNpc for an NPC that can be hurt and killed:
@Singleton
public class BossNpc extends Npc<Zombie> implements DamageableNpc {
private long deathTime;
public BossNpc() {
super(Zombie.class, "BOSS", "arena", new Location(Bukkit.getWorld("arena"), 0.5D, 64.0D, 0.5D));
}
@Override
public Component getDisplayName() {
return Component.text("The Warden of Ash", NamedTextColor.RED);
}
@Override
protected boolean hasAI() {
return true;
}
@Override
protected void onSpawn(final Zombie entity) {
entity.getAttribute(Attribute.MAX_HEALTH).setBaseValue(500.0D);
entity.setHealth(500.0D);
}
@Override
public boolean canRespawn() {
return System.currentTimeMillis() - this.deathTime >= TimeUnit.MINUTES.toMillis(10L);
}
@Override
public void onCustomDeathEvent(final CustomDeathEvent event) {
this.deathTime = System.currentTimeMillis();
event.getDrops().clear();
}
}An NPC that does not implement it has every hit cancelled, whether or not the active damage pipeline respects the invulnerability flag, so a shopkeeper is safe even under custom damage.
Damage and death each reach the NPC through exactly one callback, decided by which framework subsystems are registered:
| Registered | Damage Callback | Death Callback |
|---|---|---|
| Neither | onEntityDamageByEntityEvent |
onEntityDeathEvent |
| Death system only | onEntityDamageByEntityEvent |
onVanillaDeathEvent |
| Damage system only | onCustomDamageEvent |
onEntityDeathEvent |
| Damage and death systems | onCustomDamageEvent |
onCustomDeathEvent |
canRespawn is only consulted after an actual death. An NPC missing for any other reason, such as its chunk unloading, comes back as soon as it can, as does one that has never spawned. It defaults to false, so a DamageableNpc that says nothing stays gone once killed.
Under the damage pipeline, a DamageableNpc's death is announced like a player's. In any death message, an NPC with a display name is named by it, whether it died or did the killing.
There is no spawn call to make. A scheduler runs once a second and spawns every NPC with no entity whose location is in a loaded chunk, through the cancellable NpcPreSpawnEvent and then canSpawn.
The entity is spawned non-persistent and tagged with custom:npc_identifier, so it is never saved to the chunk and never duplicates across a restart. When its chunk unloads the entity is discarded with it, and the NPC comes back once the chunk loads again. Any tagged entity left in the world, such as one that survived a reload, is removed as soon as it is found.
Two events bracket each spawn. NpcInitializeEvent fires once every base setting is applied but before the entity is added to the world, so a change a listener makes is in place before any client sees it. NpcPostSpawnEvent fires once the entity is in the world and tracked.
// Look an NPC up by its identifier
this.npcManager.getNpcByIdentifier("BOSS").ifPresent(npc -> this.npcManager.despawn(npc));
// Resolve the NPC behind an entity, including one that has already died
this.npcManager.getNpcByTag(entity).ifPresent(npc -> UtilMessage.log("NPC", npc.getIdentifier()));getNpcByEntity only resolves entities that are still tracked, while getNpcByTag reads the entity's tag and works on one that has already been untracked, such as after a death.
despawn is not a way to hide an NPC, since the next pass spawns it again. Use canSpawn or a cancelled NpcPreSpawnEvent for that.
The framework sends server resource packs from a JSON list, through the Adventure multi-pack API. Each pack is addressed by its own identifier and sent without replacing anything, so packs stack, and applying or removing one never disturbs another the client has loaded.
Requires @Scan("io.github.trae.spigot.framework.resourcepack").
Resourcepack.json holds every setting. It is a system configuration, so it lives in the data folder of the first plugin that scans the resource pack package. Resource packs are off until enabled is set.
{
"enabled": true,
"prompt": "",
"kickMessage": "<red>You must accept the resource pack to play.",
"resourcePacks": [
{"id": "4b1d6f0e-2c7a-4e93-9a58-1f3e7c0d2b64", "url": "https://example.com/core.zip", "hash": "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12", "required": true, "worlds": [], "permission": ""},
{"id": "c83a0f52-91d7-4b6e-8e2f-7a4c5d19b0e3", "url": "https://example.com/lobby.zip", "hash": "", "required": false, "worlds": ["lobby"], "permission": ""}
]
}| Setting | Controls |
|---|---|
enabled |
Whether any pack is sent, false by default |
prompt |
The MiniMessage prompt on the download screen, or empty for vanilla's |
kickMessage |
The MiniMessage kick message for a required pack that fails |
resourcePacks |
The packs, in stacking order |
Each entry in resourcePacks holds its own settings:
| Setting | Controls |
|---|---|
id |
The pack's identifier, generated once and kept |
url |
The direct download URL, where an entry with none is never sent |
hash |
The SHA-1 of the zip as 40 hex characters, or empty to skip verification |
required |
Whether declining it or failing to load it kicks the player |
worlds |
The worlds the pack is active in, or empty for every world |
permission |
The permission needed to receive it, or empty for none |
Packs apply in list order, so a later entry overrides assets from an earlier one. A fresh file is seeded with one empty entry to show the shape.
On join, every pack the player is eligible for goes out in a single request, so the player sees one prompt. The request is required if any pack in it is.
On a world change, only the difference is sent: packs eligible in the old world and not the new one are removed, and packs eligible only in the new one are applied. A pack eligible in both stays loaded, with no second download.
A required pack that is declined, fails to download, has an invalid URL or fails to reload kicks the player with kickMessage. The check is per pack, so an optional pack failing alongside a required one kicks nobody.
Permission is checked on join and on world change only, so granting or revoking it mid-session takes effect on the player's next world change or rejoin.
ResourcePackApplyEvent fires once per pack, after its world and permission checks pass. Cancelling it skips that pack alone, leaving the rest of the request intact:
@EventHandler
public void onResourcePackApply(final ResourcePackApplyEvent event) {
if (!event.getResourcePack().isRequired() && this.settingsManager.hasCosmeticPacksDisabled(event.getPlayer())) {
event.setCancelled(true);
}
}The framework takes vanilla damage over entirely. The vanilla event is cancelled the moment it fires, a three-stage chain runs in its place, and the resolved figure is applied by hand.
That is a heavier intervention than the other subsystems make, and it buys two things. Damage becomes something a plugin composes rather than overwrites, so an ability, a weapon, a set of armour and a potion effect can each contribute without any of them knowing about the others. And the numbers become yours: the vanilla behaviour ships as the default, expressed in ordinary listeners that a game mode can replace or rebalance piece by piece.
Requires @Scan("io.github.trae.spigot.framework.damage").
Every hit passes through three stages, each its own event, each cancellable. Cancelling stops the chain, so a later stage never runs against damage that was refused.
| Stage | For |
|---|---|
CustomPreDamageEvent |
Gating the hit and establishing the base |
CustomDamageEvent |
Consuming plugins setting ability damage |
CustomPostDamageEvent |
Reductions and side effects |
The pre stage is where the damage delay is enforced, where the weapon's contribution is resolved from the held item, and where a critical hit is recognised. By the time it ends, the base reflects what the attack is worth before anything custom touches it.
A player's melee hit starts from a base of zero, since vanilla's own figure already carries the held item, sharpness, the attack charge and the critical multiplier, all of which the pipeline resolves itself. Every other hit, a mob's attack included, starts from vanilla's figure, since that damage comes from attributes the pipeline does not resolve.
The damage stage is reserved. Nothing in the framework writes damage there, so an ability has the field to itself:
@EventHandler
public void onCustomDamage(final CustomDamageEvent event) {
if (!this.isFrostbite(event)) {
return;
}
event.setDamage(7.0D);
event.removeModifier(DamageModifier.WEAPON);
}The post stage applies armour, protection and resistance early, then durability, knockback and the delay record at the end. Once it completes, DamageManager deals what is left.
Cancelling vanilla's damage handling also cancels the sound an entity makes when struck, so the pass carries one of its own as a SoundProvider. It is seeded from the damagee rather than the weapon, so a zombie grunts and a skeleton rattles, and it plays under the entity's own sound category, so it follows the same client volume slider vanilla's would.
The sound is settable at any stage and read only once the damage lands, so an ability replaces it rather than playing its own alongside it:
@EventHandler
public void onCustomDamage(final CustomDamageEvent event) {
if (!this.isFrostbite(event)) {
return;
}
event.setSoundProvider(SoundProvider.of(Sound.BLOCK_GLASS_BREAK, SoundCategory.PLAYERS, 1.0F, 1.6F));
}Set it to null for a silent hit. It is skipped on a hit that kills, since the death sound covers that, and a non-living damagee has none to begin with.
The damage figure is a base plus a set of named contributions, rather than a single number listeners fight over. Each key holds at most one additive and one multiplier, so two things writing to different keys compose and two things writing to the same key do not.
| Key | Holds |
|---|---|
WEAPON |
The attacking item's own damage |
CRITICAL |
The critical hit multiplier |
POTION |
Strength and weakness on the attacker |
ARMOUR |
The reduction from worn armour |
PROTECTION |
The reduction from protection enchantments |
RESISTANCE |
The reduction from the resistance effect |
CUSTOM |
Anything a consuming plugin contributes |
// One thing lives under this key, so replace it
event.setModifier(DamageModifier.WEAPON, 6.0D);
// Several things stack under this key, so add to it
event.addModifier(DamageModifier.ARMOUR, -2.5D);
// Proportional rather than flat
event.setMultiplier(DamageModifier.CRITICAL, 1.5D);
// Discard a contribution an ability does not want
event.removeModifier(DamageModifier.WEAPON);getFinalDamage() applies every additive to the base first, then every multiplier, and floors the result at zero. That ordering is why a reduction written as a negative additive is still scaled by a multiplier written elsewhere, which is what makes armour and resistance compose the way vanilla's do.
A plugin that wants to deal damage directly builds a pre stage and dispatches it, rather than calling damage and having the pipeline intercept it:
// Environmental, with no attacker
UtilEvent.dispatch(CustomPreDamageEvent.create(target, DamageCause.MAGIC, 4.0D, Reason.of(Component.text("Trap"))));
// Attributed to an attacker, so the kill is credited and mobs retaliate
UtilEvent.dispatch(CustomPreDamageEvent.create(target, player, DamageCause.MAGIC, 4.0D, Reason.of(Component.text("Ignite"))));Both go through the full chain, so a delay still applies, armour still reduces, and the death system still sees it. Neither carries a hit sound, since there is no swing behind it, so set one on the event before dispatching if the damage should be heard.
A reason is what the damage is attributed to in messages. The pipeline seeds one from the attacker's held item, complete with its display name and hover tooltip, so a death message can show what killed someone without rebuilding it.
A CustomReason carries a duration and outlives the hit that set it. That is what an ability wants: the attribution should survive the attacker swapping items, and should still name the ability if the kill lands a few seconds later.
// Stands for five seconds, whatever the killing blow turns out to be
event.setReason(CustomReason.of(Component.text("Frostbite 5"), TimeUnit.SECONDS.toMillis(5L)));
// Stands until the target dies
event.setReason(CustomReason.of(Component.text("Bleed")));The manager retains one of these per damagee and attacker pair, and the death system reads it before falling back to the killing hit's own reason. A plain reason reads with an article in front of it, so an item renders as "with a Diamond Sword"; a custom one does not, so an ability renders as "with Frostbite 5".
Because the pipeline owns everything, every piece of vanilla behaviour it replaced ships as a listener. Each is ordinary code with no special standing, which is what makes the whole set replaceable.
| Listener | Provides |
|---|---|
DamageWeaponReductionListener |
A player's melee damage from the held item, by material, plus sharpness, scaled by attack charge in vanilla combat |
DamageCriticalListener |
The critical hit multiplier |
DamageArmourReductionListener |
Armour, toughness and protection reduction |
DamagePotionEffectListener |
Strength and weakness on a player's melee hit, and resistance on the damagee |
DamageWeaponDurabilityListener |
One durability point per hit on the attacker's item |
DamageArmourDurabilityListener |
A quarter of the damage, floored at one, on each worn piece |
DamageKnockbackListener |
Knockback away from the attacker, with resistance applied |
DamageDelayListener |
The immunity window for every cause, shared for PvP in vanilla combat and per source otherwise |
DamageIntervalListener |
A hit offered every tick for causes vanilla times itself, so their window sets their rate |
DamageAttackSpeedListener |
The attack cooldown, restored or removed depending on the combat mode |
The figures match vanilla: armour points and toughness by material, the twenty-point cap and the division by twenty-five, a point for the first level of sharpness and half a point for each after, three damage per level of strength and four less per level of weakness, twenty percent per resistance level, and the displayed attack damage for every weapon including copper. Whether armour, protection and resistance apply is decided by the damage type's tags, as vanilla decides it, so armour does nothing against drowning or poison, protection does nothing against starvation, and resistance does nothing against /kill.
Every formula figure is read from Damage.json on each hit, while the per-material tables stay in code, since per-item balance already has its own events. See Rebalancing.
Damage.json holds every combat setting. It is a system configuration, so it lives in the data folder of the first plugin that scans the damage package. The defaults match vanilla.
| Setting | Controls |
|---|---|
oldCombatEnabled |
Whether combat follows pre-1.9 rules rather than vanilla's, false by default |
oldCombatAttackSpeed |
The attack speed players are given while old combat is enabled |
delay |
The immunity window per damage cause, and the default for any cause without one |
interval |
What each extra hit of burning, drowning, freezing and starvation deals, and the health starvation stops at |
critical |
Whether critical hits deal extra damage, and the multiplier |
knockback |
The strength of the push and the most upward velocity a grounded target is given |
weaponReduction |
The sharpness bonus, and the attack charge curve for the weapon's damage |
armourReduction |
The armour cap and divisor, the toughness formula, and the protection cap and divisor |
potionEffect |
Strength and weakness per level, resistance per level, and the attack charge curve for strength and weakness |
A reload takes effect on the next hit, since every listener reads the same instance and a reload updates it in place. Attack speed is the exception, being written onto each player rather than read per hit, so it is pushed to everyone online when the file is reloaded.
A section added in a later version is written into an existing file on its next load. A value already in the file is kept, including the delay map, so a cause added to its defaults later only appears in a fresh file.
oldCombatEnabled decides which combat rules apply.
With old combat disabled, combat matches vanilla 1.21. The attack cooldown applies, and a player's melee damage is scaled by how charged the swing was: the item's damage and any strength or weakness by a fifth plus four fifths of the charge squared, and sharpness by the charge alone. Both curves are configurable, under weaponReduction and potionEffect. A player hit by a player gets one immunity window shared by every player, so a hit from one briefly protects against all of them.
With old combat enabled, attack speed is raised to oldCombatAttackSpeed, high enough that every swing lands at full strength, and PvP uses the same per attacker windows as everything else, so two players can hit the same target at once.
Every hit that is not PvP is windowed per attacker and per environmental cause in both modes. A mob or the environment never blocks a player's hit, a player's hit never blocks them, and burning does not protect against drowning.
The window's length comes from the hit's cause in both modes, falling back to defaultValue for a cause with no value of its own. The window is the only thing spacing hits out, since cancelling vanilla's damage also discards its invulnerability frames.
Vanilla fires some causes on intervals of its own, hardcoded into the game, and a window could only ever slow those down. DamageIntervalListener offers each of them a hit on every tick for as long as the entity is still in the state that causes it, so for those causes the window is the actual rate:
| Cause | Default | Vanilla's own interval |
|---|---|---|
POISON |
1250 |
25 ticks, halved per level |
WITHER |
2000 |
40 ticks, halved per level |
FIRE_TICK |
1000 |
20 ticks |
DROWNING |
1000 |
20 ticks |
FREEZE |
2000 |
40 ticks |
STARVATION |
4000 |
80 ticks |
The defaults match vanilla at the first effect level. Setting POISON to 100 makes poison hit every two ticks, whatever its level, since the window alone now sets the pace. Nothing can hit more than once a tick, so any value under 50 behaves as 50.
Each extra hit goes through vanilla's own damage call, with vanilla's source and cause, so it keeps vanilla's own checks: fire resistance and fire immunity, poison stopping at one health, and starvation stopping at a floor on peaceful, easy and normal. Burning, drowning, freezing and starvation deal the amounts set under interval, as do their starvation floors. Poison and wither deal what their effect deals, since their hits run inside the effect's own code. Poison and wither fire EntityEffectTickEvent first, as vanilla does. Vanilla's own interval hits still run alongside, and the window refuses them like any other, so nothing is dealt twice.
Poison, wither and burning are picked up the moment they start. Drowning, freezing and starvation are picked up from their first vanilla hit, so they begin one vanilla interval in.
Every other cause already fires as often as vanilla allows. Contact damage, such as lava, fire, cactus and magma, fires every tick once invulnerability frames are gone, so its window is already its rate.
A player's melee hit starts from a base of zero, so the effects vanilla folds into the attack damage attribute are added back by DamagePotionEffectListener, under POTION. Strength adds and weakness removes a set amount per level, scaled by the attack charge in vanilla combat. Weakness never takes the hit below what the weapon itself deals, as vanilla floors the attribute at zero. A mob's attack already carries both in vanilla's figure, so only players are resolved.
Resistance reduces damage by a share per level, under RESISTANCE, floored at total immunity.
Everything else is left to what already handles it: poison and wither to DamageIntervalListener, absorption to DamageManager, fire resistance to vanilla's own invulnerability check, and jump boost and slow falling to vanilla's fall damage, which they reduce before the event fires.
The two reduction events are per item, which is the hook a game mode uses to move off vanilla's balance without replacing the formula.
@EventHandler
public void onWeaponReduction(final WeaponReductionEvent event) {
if (event.getItemStack().getType() == Material.IRON_SWORD) {
event.setAmount(4.0D);
}
}
@EventHandler
public void onArmourReduction(final ArmourReductionEvent event) {
if (event.getItemStack().getType() == Material.DIAMOND_CHESTPLATE) {
event.setAmount(24.0D);
event.setToughness(0.0D);
}
}WeaponReductionEvent sets what that item is worth in damage. ArmourReductionEvent sets what that piece is worth in armour points, and its toughness separately; every piece's value is summed and the total goes through the reduction formula, so raising one piece makes the whole set stronger. Setting toughness to zero collapses the formula to a flat armour curve, which is the pre-1.9 shape.
Cancelling either excludes that item from the calculation entirely.
DamageManager deals the resolved figure and reproduces what vanilla would have done: health and absorption, the hurt animation and flash, the combat tracker that names a killer in death messages, mob aggro, statistics, advancements, death protection, and death itself.
Aggro is the one worth knowing about. Marking the attacker as the damagee's last attacker is a single field write, and it is what makes a spider turn on a player who hits it in daylight, and what angers neutral mobs. Without it, custom damage would be invisible to mob behaviour.
Death protection is the other. Vanilla's totem check is private, so it is reproduced here: when a hit would kill, a death protection item in either hand fires EntityResurrectEvent, and on success the item is consumed, health is set to half a heart, the item's effects are applied and the totem animation plays. The entity never dies, so no death event fires. Any item carrying the death protection component counts, not only a totem, and sources that bypass invulnerability, such as the void, are never protected against.
One piece of vanilla behaviour remains unreachable from a plugin, because the method behind it is not visible: the damagee's own last damage source. The retained damage pass stands in for it, which is what the death system reads.
The death system turns a vanilla death into one that knows what caused it.
Vanilla's own death handling only knows that an entity died. The damage pipeline knows who dealt the blow, with what item, and for what reason, so the two are joined here: DamageManager retains the last damage pass that landed on each entity, and the death listener reads it.
Requires @Scan("io.github.trae.spigot.framework.death"). The damage system is optional.
Which event a death gets depends on whether the damage system is registered:
| Event | Dispatched When | Knows |
|---|---|---|
CustomDeathEvent |
The damage system is registered and has a record of the killing pass | The pass itself, with the item, both names, the cause and the resolved reason |
VanillaDeathEvent |
The damage system is not registered | Only what the entity and the vanilla damage source still hold, with the killer's held item standing in for the reason |
With the damage system registered, a death the pipeline has no record of dispatches nothing, since there is no damage behind it to report.
Both implement DeathEvent, which carries the entity, the killer, the cause, the reason, and the parts a listener can still change. Code that only needs those takes the interface and works whichever shape produced it:
@EventHandler
public void onCustomDeath(final CustomDeathEvent event) {
this.onDeath(event);
}
@EventHandler
public void onVanillaDeath(final VanillaDeathEvent event) {
this.onDeath(event);
}
private void onDeath(final DeathEvent event) {
final Entity killer = event.getKiller();
if (killer == null) {
return;
}
this.statisticsManager.addKill(killer);
}Both are dispatched from inside the vanilla death event at its last priority, so the death itself is settled and neither is cancellable. A vanilla death another plugin cancelled is skipped outright, since nothing actually died.
Attribution is resolved before CustomDeathEvent is dispatched rather than read straight off the damage pass. A killer with an unexpired CustomReason standing against the target is credited with that instead of with whatever the killing hit happened to be, so a kill landed with a sword moments after an ability still names the ability. Both retained records are dropped only after dispatch, so a listener reading them for the same entity still finds them.
Three things are still open when the event fires, and whatever listeners leave them as is written back to the vanilla death:
@EventHandler
public void onCustomDeath(final CustomDeathEvent event) {
event.getDrops().removeIf(itemStack -> itemStack.getType() == Material.ROTTEN_FLESH);
event.setDropExp(event.getDropExp() * 2);
event.setSoundProvider(SoundProvider.of("custom:entity.boss.death", SoundCategory.HOSTILE, 2.0F, 1.0F));
}The drop list is the event's own copy, and replaces the vanilla list wholesale once dispatch ends. Experience is copied back as it stands.
The death sound starts as the vanilla one, and how it is written back depends on what it names:
| Sound | Result |
|---|---|
| A sound the server knows | Replaces vanilla's death sound, with its category, volume and pitch |
| A sound only a resource pack knows | Vanilla's is silenced, and this one is played at the entity's location instead |
null |
The death is silent |
A death vanilla already keeps silent, such as a silent entity's, stays silent whatever the listener sets.
Vanilla's message is suppressed and replaced. The replacement is dispatched once per recipient rather than broadcast, so a plugin can vary it by who is reading it, or suppress it for some players and not others:
@EventHandler
public void onCustomDeathMessage(final CustomDeathMessageEvent event) {
if (this.settingsManager.hasDeathMessagesHidden(event.getRecipient())) {
event.setCancelled(true);
}
}The event carries the DeathEvent behind it, so a listener reads the entity and killer the same way whichever shape produced the death. Both names are DisplayNames, settable per recipient, so a team colour, a rank or a class tag replaces only that recipient's copy:
@EventHandler
public void onCustomDeathMessage(final CustomDeathMessageEvent event) {
if (event.getDeathEvent().getEntity() instanceof final Player player) {
event.setEntityName(DisplayName.of(this.getRankPrefix(player), Component.text(player.getName()), null));
}
}The message takes one of three shapes: a self-inflicted death names nobody, a death with a killer names them and what it was attributed to, and anything else names the cause.
Messages are only produced for player deaths, and for the deaths of a DamageableNpc under the damage pipeline, which the NPC system announces like a player's. Other mob deaths still fire the death events, so a plugin wanting to announce those listens to them directly.
The framework provides a timed effect system for any LivingEntity. An effect holds its own users, each with an amplifier and a duration, and can bind itself to a vanilla potion effect that the framework keeps in sync across every transition.
Requires @Scan("io.github.trae.spigot.framework.effect").
Extend Effect, passing a name, and register it as a component. EffectManager discovers every subclass automatically through the dependency injector:
@Singleton
public class BleedEffect extends Effect {
public BleedEffect() {
super("Bleed");
}
@Override
protected void onTick(final LivingEntity livingEntity, final EffectData effectData) {
livingEntity.damage(effectData.getAmplifier() * 0.5D);
}
}Effects are collected once the server has finished starting rather than on plugin enable, so an effect registered by any plugin is picked up regardless of enable order.
// Amplifier 2 for 10 seconds
this.bleedEffect.addUser(player, 2, TimeUnit.SECONDS.toMillis(10L));
// Amplifier 0, so no potion effect is sent
this.bleedEffect.addUser(player, TimeUnit.SECONDS.toMillis(10L));Amplifiers are one-based, so 1 is level I, matching how a player reads it rather than how Bukkit stores it. Durations are milliseconds throughout, converted to ticks only where a packet needs them.
Override getPotionEffectType() and the framework applies, re-applies and clears the vanilla effect alongside its own state:
@Singleton
public class FrostEffect extends Effect {
public FrostEffect() {
super("Frost");
}
@Override
protected PotionEffectType getPotionEffectType() {
return PotionEffectType.SLOWNESS;
}
}Left unbound, which is the default, the effect is purely logical and nothing vanilla is sent.
updateUser hands the stored EffectData to a consumer and reconciles the potion effect with whatever the consumer leaves behind:
// Extend by 5 seconds, keeping the time already served
this.frostEffect.updateUser(player, true, effectData -> effectData.setDuration(effectData.getDuration() + TimeUnit.SECONDS.toMillis(5L)));
// Raise the amplifier and start the new duration from scratch
this.frostEffect.updateUser(player, false, effectData -> {
effectData.setAmplifier(effectData.getAmplifier() + 1);
effectData.setDuration(TimeUnit.SECONDS.toMillis(30L));
});The carryOver flag decides what happens to time already served when the duration grows. With it set, the potion effect is re-applied for the time remaining plus the increase, so a player 30 seconds into a one minute effect that is raised to two minutes ends up with a minute and a half. Without it, the new duration replaces what was left outright.
Zeroing the duration clears the potion effect but keeps the entry, so an effect can sit dormant and be revived by a later update. Zeroing the amplifier ends the effect entirely.
Each transition has a guard that can refuse it and a hook that runs once it has happened:
| Override | Runs |
|---|---|
canAdd |
Before the effect is stored, to refuse it |
canRemove |
Before a deliberate removal, to refuse it |
canUpdate |
Before the update consumer runs, to refuse it |
onAdd |
Once the effect is stored and its potion effect sent |
onUpdate |
Once the data is mutated and the potion effect reconciled |
onRemove |
Once the effect is removed, expiry aside |
onExpire |
Once the duration has elapsed |
onRemoveOrExpire |
On every path that ends the effect, after the path's own hook |
onTick |
Every tick, for each live holder that has not expired |
onRemoveOrExpire is the one to use for teardown that must happen however the effect ended, rather than duplicating it across onRemove and onExpire.
An effect is a component like any other, so it can implement Listener and be registered alongside everything else. That suits an effect whose whole behaviour is a reaction to something rather than a per-tick one, since the effect's state and the handler that reads it live in the same class:
@Singleton
public class NoFallEffect extends Effect implements Listener {
public NoFallEffect() {
super("No Fall");
}
@EventHandler
public void onEntityDamage(final EntityDamageEvent event) {
if (event.isCancelled()) {
return;
}
if (event.getCause() != EntityDamageEvent.DamageCause.FALL) {
return;
}
if (!(event.getEntity() instanceof final LivingEntity livingEntity)) {
return;
}
if (this.getUserByLivingEntity(livingEntity).isEmpty()) {
return;
}
event.setCancelled(true);
}
}getUserByLivingEntity is the membership check, and it doubles as the read when the handler needs the amplifier or the time left:
this.getUserByLivingEntity(livingEntity).ifPresent(effectData -> event.setDamage(event.getDamage() / effectData.getAmplifier()));The handler runs on whatever thread the event does, so an effect that is also a listener can read entity state directly, unlike onTick, which runs on the manager's own schedule.
// Deliberate removal, the one path a listener can refuse
this.frostEffect.removeUser(player);Everything else is the framework's own doing, and carries a reason:
| Reason | Cause |
|---|---|
NORMAL |
A deliberate removeUser call |
EXPIRE |
The duration elapsed |
CONDITIONAL |
removeOnCondition returned true during the tick |
DEATH |
The holder died and the effect overrides removeOnDeath |
QUIT |
The holder disconnected and the effect overrides removeOnQuit |
Only NORMAL consults canRemove and EffectPreRemoveEvent, since it is the one case where the removal is still a proposal. The rest report something that has already happened and are applied unconditionally.
removeOnCondition is checked every tick, for state the effect wants to end itself on:
@Override
protected boolean removeOnCondition(final LivingEntity livingEntity, final EffectData effectData) {
return livingEntity.isInWater();
}
@Override
public boolean removeOnDeath() {
return true;
}
@Override
public boolean removeOnQuit() {
return true;
}Both removeOnDeath and removeOnQuit default to false, so an effect survives a death or a reconnect unless it says otherwise.
// Every user, including entities that are currently unloaded
final Map<UUID, EffectData> users = this.frostEffect.getUsers();
// Only the users whose entity can be resolved right now
final Map<LivingEntity, EffectData> activeUsers = this.frostEffect.getActiveUsers();
// One user
final Optional<EffectData> effectData = this.frostEffect.getUserByLivingEntity(player);getActiveUsers is a snapshot rebuilt per call, and it silently omits any user whose entity is offline or unloaded, so getUsers stays the source of truth for membership and counts.
The manager ticks every 50ms and walks each effect's users, resolving the entity, checking expiry and dispatching the tick event. An effect with no users costs a map iteration and nothing else, so the cost scales with how many entities actually hold an effect rather than with how many effects exist.
The user map is pruned in place during that walk, so a hook invoked from it must not add or remove users of the same effect directly.
The chat system takes vanilla chat over and routes every message through a channel, such as global, staff, faction or ally chat.
The framework keeps no record of who is in which channel. That state belongs to the plugin, such as a channel stored on the player's account, and the framework asks for it on every message. That keeps one source of truth, and lets any number of plugins take part in deciding where a message goes.
Requires @Scan("io.github.trae.spigot.framework.chat"), and a DefaultChatChannel declared by the plugin that owns chat.
Vanilla's chat event is cancelled at its last priority, so every other plugin sees it first and vanilla never sends the message. Three events carry it from there, in order:
| Event | For |
|---|---|
ChatChannelEvent |
Resolving which channel the message is sent in |
ChatSendEvent |
Refusing or changing the message for everyone |
ChatReceiveEvent |
Refusing or changing one recipient's copy |
Each copy that survives its receive event is formatted by its channel and delivered to its recipient.
ChatSwitchChannelEvent sits outside that flow. It is how a player moves between channels, and is dispatched by the plugin rather than the framework.
A message another plugin already cancelled on the vanilla event is left alone.
A channel decides who receives a message and how it reads, both per sender, so one implementation serves every player in it:
@Singleton
public class StaffChatChannel implements ChatChannel {
@Override
public String getName() {
return "Staff";
}
@Override
public List<Player> getRecipients(final Player sender) {
return UtilServer.getOnlinePlayers().stream().filter(player -> player.hasPermission("core.staffchat")).toList();
}
@Override
public Component getFormat(final Player sender, final Component message) {
return Component.text("[Staff] ", NamedTextColor.RED).append(Component.text(sender.getName(), NamedTextColor.YELLOW)).append(Component.text(": ", NamedTextColor.WHITE).append(message.colorIfAbsent(NamedTextColor.WHITE)));
}
}The sender is not added to the recipients automatically, so include them if they should see their own message. A faction channel resolves the sender's own faction inside getRecipients rather than existing once per faction.
Exactly one channel implements DefaultChatChannel. It is what every message starts in, and what a player chats in when their state names no other channel:
@Singleton
public class GlobalChatChannel implements DefaultChatChannel {
@Override
public String getName() {
return "Global";
}
@Override
public List<Player> getRecipients(final Player sender) {
return UtilServer.getOnlinePlayers();
}
@Override
public Component getFormat(final Player sender, final Component message) {
return Component.text(sender.getName(), NamedTextColor.YELLOW).append(Component.text(": ", NamedTextColor.WHITE).append(message.colorIfAbsent(NamedTextColor.WHITE)));
}
}ChatManager takes it through its constructor, so a plugin that enables the chat package without declaring one fails at startup rather than silently dropping every message.
ChatChannelEvent is fired for every message, seeded with the default channel. A listener reads the plugin's own state and sets the channel to match:
@EventHandler(priority = EventPriority.LOW)
public void onChatChannel(final ChatChannelEvent event) {
this.accountManager.getAccount(event.getSender()).map(Account::getChatChannel).ifPresent(event::setChannel);
}Several listeners may set the channel, and the last to run wins, so priority decides between them. A state that should override a player's own choice sets it later:
@EventHandler(priority = EventPriority.HIGH)
public void onChatChannelWhileJailed(final ChatChannelEvent event) {
if (this.jailManager.isJailed(event.getSender())) {
event.setChannel(this.jailChatChannel);
}
}The event is not cancellable, since every message needs a channel. Refusing a message belongs on the send event.
A command that moves a player between channels dispatches ChatSwitchChannelEvent, so every switch passes through one event whichever command caused it:
@Override
public void execute(final Player player, final String[] args) {
UtilEvent.dispatch(new ChatSwitchChannelEvent(player, this.staffChatChannel));
}The framework does not act on it. The plugin records the new channel once the event survives, which is the state ChatChannelEvent reads back on the next message:
@EventHandler(priority = EventPriority.MONITOR)
public void onChatSwitchChannel(final ChatSwitchChannelEvent event) {
if (event.isCancelled()) {
return;
}
this.accountManager.getAccount(event.getPlayer()).ifPresent(account -> account.setChatChannel(event.getChannel()));
UtilMessage.message(event.getPlayer(), "Chat", "You are now chatting in <green>%s</green>.".formatted(event.getChannel().getName()));
}Recording at MONITOR keeps the state in step with the outcome, since a listener that cancels the switch at any earlier priority has already had its say. Cancelling refuses the switch, such as a player without the rank for staff chat or with no faction to chat in.
ChatSendEvent is fired once per message, after the channel is settled. Cancel it to refuse the message for everyone, or change the message to change it for everyone:
@EventHandler
public void onChatSend(final ChatSendEvent event) {
if (this.punishmentManager.isMuted(event.getSender())) {
event.setCancelled(true);
UtilMessage.message(event.getSender(), "Chat", "You are muted.");
}
}A message that survives is split into one ChatReceiveEvent per recipient the channel names. Cancel one to hide the message from that recipient alone, or change its message to vary it for them:
@EventHandler
public void onChatReceive(final ChatReceiveEvent event) {
if (this.ignoreManager.isIgnoring(event.getRecipient(), event.getSender())) {
event.setCancelled(true);
}
}Each copy that survives its own event is passed through the channel's getFormat and delivered to its recipient as a server message. The format is applied last, to the message that recipient's event settled on, so a listener changing one copy changes only the message inside the line, and the channel's wrapping stays the same for everyone.
The channel is fixed once the send event fires. A listener that needs a different channel sets it on ChatChannelEvent instead.
A message a player types is handled off the main thread, so the channel, send and receive events, and a channel's getRecipients and getFormat, all run there. Keep them to state that is safe to read from another thread, and schedule anything that touches the world back onto the main thread.
A SoundProvider is a sound together with the category, volume and pitch to play it at, built once and replayable anywhere. The damage pass and both death events carry one.
The sound is held as its namespaced key rather than as a Sound, so a sound that exists only in a resource pack is named as easily as a vanilla one, and the value survives being written to configuration and read back:
// A vanilla sound, converted to its key through the registry
SoundProvider.of(Sound.ENTITY_PLAYER_LEVELUP, SoundCategory.PLAYERS, 1.0F, 1.2F);
// A resource pack sound, by key
SoundProvider.of("custom:ui.unlock", SoundCategory.MASTER, 1.0F, 1.0F);
// Full volume and normal pitch
SoundProvider.of(Sound.BLOCK_NOTE_BLOCK_PLING, SoundCategory.RECORDS);// Heard by everyone in range of a location
soundProvider.play(location);
// Heard by one player, following them as they move
soundProvider.play(player);
// Heard by every online player from their own position
soundProvider.broadcast();Pick the category vanilla would use for the same kind of sound, so players can turn it down with the slider they expect: PLAYERS for anything a player's own gear or body makes, HOSTILE or NEUTRAL for mobs, and MASTER only for something that must always be heard.
Nothing here throws. A null sound, a sound with no registered key, a null key or a null category all yield a provider that plays nothing, so a provider can be built straight from a nullable source, such as the death sound of an entity that has none, without a guard. getSound() resolves the key back to a Sound, and is empty for a resource pack sound, which still plays.
A DisplayName is a name in three parts: whatever sits before it, the name itself, and whatever sits after it.
Keeping the parts separate rather than pre-joined means a consumer can take just the name where a prefix would be noise, such as a compact scoreboard line, and the full thing where it would not. Either side may be absent, which is the normal case for anything unranked or untagged.
// With a rank in front and a tag behind
DisplayName.of(rankComponent, nameComponent, tagComponent);
// Nothing either side
DisplayName.of(nameComponent);getFullName() joins the parts with no separator and skips the absent ones, so any spacing a caller wants between the parts belongs in the prefix or suffix itself:
// Renders as "[Admin] Steve"
DisplayName.of(Component.text("[Admin] "), Component.text("Steve"), null);The damage pipeline carries one for each side of a hit, so a plugin with display names, ranks or nicknames writes them once at the pre stage rather than at every message site. The death message event carries one for each side too, settable per recipient.
UtilNms provides direct access to NMS operations without requiring each consumer to handle CraftBukkit casting:
// Convert Adventure component to vanilla Minecraft component
net.minecraft.network.chat.Component nmsComponent = UtilNms.toNms(adventureComponent);
// Send a raw NMS packet to a player (safe from any thread)
UtilNms.sendPacket(player, packet);Packet sending writes directly to the Netty channel pipeline, bypassing the main thread. This is what enables the sidebar and team systems to run without blocking the main thread.
The damage system reaches deeper than this, driving vanilla's own damage internals directly rather than sending packets. See Damage System.
| Utility | Description |
|---|---|
UtilEvent |
Synchronous and asynchronous event dispatch with supply variants |
UtilTask |
Task scheduling: immediate, synchronous, asynchronous, and repeating with ChronoUnit-to-tick conversion |
UtilMessage |
MiniMessage-based messaging with configurable prefixes, broadcasting, filtering, and ignore lists |
UtilPlugin |
Plugin lookup, internal by name or class |
UtilNms |
NMS packet sending and Adventure-to-vanilla component conversion |
UtilItemStack |
Persistent data reads and writes on an ItemStack |
UtilWindow |
Opening a Window for a player, honouring the open event and gate |
UtilHologram |
Spawning, updating and despawning a Hologram for a single player |
UtilDamage |
Resolving whether an attack qualifies as a critical hit |
UtilMaterial |
Classifying materials by whether a block responds to a click, to a held item, or whether an item has a use of its own |
UtilAdventure |
Joining components with a separator and an inclusion filter, skipping nulls and empties |
UtilColor |
Converting between AWT, Adventure and Bukkit colours, and wrapping text in MiniMessage colour tags |
UtilMap |
Scaling an image to a grid of maps and matching its pixels to the map palette |
UtilServer |
Server and online player access |
| Type | Extend To |
|---|---|
Item |
Describe a stack with no identity, such as a window icon |
CustomItem |
Describe a stack that carries an identifier and version, and is reconciled automatically |
ActivatableCustomItem |
Add a click action, branching on the click type |
SingleActivatableCustomItem |
Add a click action for one fixed click type |
ChannelCustomItem |
Add an action that runs every tick while right click is held |
Window |
Define an inventory menu composed of buttons |
Button |
Define a clickable slot within a window |
Sidebar |
Define a priority-sorted scoreboard sidebar |
Tablist |
Define a priority-sorted tab list header and footer |
Team |
Define a priority-sorted, per-viewer nametag decoration |
Hologram |
Define a packet-based floating text display with per-player text and visibility |
BillboardImage |
Define a still image shown across a grid of item frames |
BillboardVideo |
Define a video, from a folder of frames, shown across a grid of item frames |
Npc |
Define a non-player character backed by a real entity |
Reason |
Describe what a hit is attributed to in messages |
CustomReason |
Describe an attribution that outlives the hit that set it |
ChatChannel |
Define a chat channel with its own recipients and format |
DefaultChatChannel |
Define the channel every message starts in, exactly once |
| Type | Sender | Use Case |
|---|---|---|
BaseCommand<Plugin, Manager, CommandSender> |
CommandSender |
Any sender |
BaseCommand<Plugin, Manager, Player> |
Player |
Player-only commands |
BaseCommand<Plugin, Manager, ConsoleCommandSender> |
ConsoleCommandSender |
Console-only commands |
| SubCommand Type | Sender | Use Case |
|---|---|---|
BaseSubCommand<Plugin, Command, CommandSender> |
CommandSender |
Any sender |
BaseSubCommand<Plugin, Command, Player> |
Player |
Player-only subcommands |
BaseSubCommand<Plugin, Command, ConsoleCommandSender> |
ConsoleCommandSender |
Console-only subcommands |
| Event Type | Description |
|---|---|
CustomEvent |
Base synchronous event with Void key type |
CustomAsynchronousEvent |
Base asynchronous event with Void key type |
CustomCancellableEvent |
Synchronous event with cancellation and reason |
CustomAsynchronousCancellableEvent |
Asynchronous event with cancellation and reason |
| Event | Fired When |
|---|---|
CommandExecuteEvent |
Any command or subcommand is about to execute |
CommandTabCompleteEvent |
Any command or subcommand tab completion is requested |
All events are cancellable. Cancelling an execute event prevents execution; cancelling a tab complete event returns an empty list.
| Event | Fired When |
|---|---|
ItemMetaUpdateEvent |
An item has written its description to a stack's meta, before that meta is applied |
ItemStackUpdateEvent |
An item has finished with a stack and its meta has been applied |
ItemPreActivateEvent |
A player activated an item, before the item's action runs |
ItemPostActivateEvent |
An item's activation has run |
ItemChannelEvent |
Every tick a player is channelling an item, before the per-tick action runs |
ItemPreActivateEvent and ItemChannelEvent are cancellable. Cancelling an activation suppresses it entirely; cancelling a channel tick ends the channel outright, firing onStop. The rest are not cancellable, and the post event only fires for an activation that actually ran.
The two update events differ by what they can safely touch. ItemMetaUpdateEvent fires while the meta is open, for anything belonging to the meta. ItemStackUpdateEvent fires after it has been applied, and is the only place a data component can be set, since applying a meta replaces the stack's whole component set.
| Event | Fired When |
|---|---|
WindowOpenEvent |
A window is about to be rendered and shown to a player |
WindowCloseEvent |
A player closed a window, before its tracking entries are dropped |
ButtonPreClickEvent |
A player clicked a button, before the button's action runs |
ButtonPostClickEvent |
A button's action has run |
All but the last are cancellable. Cancelling an open aborts it, cancelling a close re-opens the window a tick later, and cancelling a click suppresses the button's action. ButtonPostClickEvent only fires for a click that actually ran.
| Event | Fired When |
|---|---|
SidebarUpdateEvent |
A sidebar update is requested for a player |
| Event | Fired When |
|---|---|
TablistUpdateEvent |
A tablist update is requested for a player, dispatched on a fixed interval |
| Event | Fired When |
|---|---|
TeamUpdateEvent |
A team prefix and suffix update is requested for a player |
| Event | Fired When |
|---|---|
HologramSpawnEvent |
A hologram is about to be sent to a player |
HologramDespawnEvent |
A hologram's remove packet has been sent to a player |
HologramUpdateEvent |
A hologram's metadata packet has been sent to a player |
Only the spawn event is cancellable, and cancelling it stops the hologram being sent. The other two are notifications dispatched after their packets have gone out. HologramUpdateEvent fires for every viewer of every dynamic hologram on each pass, so a listener on it should stay proportionate to that.
| Event | Fired When |
|---|---|
NpcPreSpawnEvent |
An NPC's entity is about to be spawned |
NpcInitializeEvent |
An NPC's entity has been configured, before it is added to the world |
NpcPostSpawnEvent |
An NPC's entity has been spawned and is being tracked |
NpcInteractEvent |
A player right-clicked an InteractableNpc, before its own gate runs |
NpcPreSpawnEvent and NpcInteractEvent are cancellable. A cancelled spawn is retried on the next scheduler pass, so a listener hiding an NPC keeps cancelling for as long as that holds. NpcInitializeEvent fires while the entity is not yet in the world, so a listener may change its equipment or attributes but must not teleport or mount it.
| Event | Fired When |
|---|---|
ResourcePackApplyEvent |
A configured pack is about to be sent to a player, once per pack |
Cancellable. Cancelling skips only that pack, leaving the rest of the request intact.
| Event | Fired When |
|---|---|
CustomPreDamageEvent |
Damage is about to be processed, before anything has been decided |
CustomDamageEvent |
The base is settled, for consuming plugins to set ability damage |
CustomPostDamageEvent |
The damage is final, for reductions and side effects |
WeaponReductionEvent |
The attacking item's damage contribution is being resolved |
ArmourReductionEvent |
A worn piece's armour value is being resolved, once per piece |
WeaponDurabilityEvent |
The attacker's item is about to spend durability |
ArmourDurabilityEvent |
A worn piece is about to spend durability, once per piece |
CustomKnockbackEvent |
Knockback has been calculated, before it is applied |
All are cancellable. Cancelling any of the three stages stops the chain there, so nothing downstream runs. Cancelling a reduction event excludes that item from the calculation entirely; cancelling a durability event spares that item; cancelling the knockback event means no knockback at all.
The three stages exist so the framework, consuming plugins and reductions each have a place that is not the others'. Writing damage at the pre stage would be overwritten by a weapon, and writing it at the post stage would miss armour.
| Event | Fired When |
|---|---|
CustomDeathEvent |
An entity has died, with the damage pass that killed it attached |
VanillaDeathEvent |
An entity has died and the damage system is not registered |
CustomDeathMessageEvent |
A death message is about to be sent to one player |
Neither death event is cancellable, since both are dispatched from inside the vanilla death once it is settled. Their drops, experience and death sound are still writable, and are written back to the vanilla death after dispatch. CustomDeathMessageEvent is cancellable, and is dispatched once per recipient, so a message can be suppressed or reworded for some players and not others.
| Event | Fired When |
|---|---|
EffectPreAddEvent |
An effect is about to be applied to an entity |
EffectPostAddEvent |
An effect has been applied and its potion effect sent |
EffectPreUpdateEvent |
An entity's effect data is about to be mutated |
EffectPostUpdateEvent |
The data has been mutated and the potion effect reconciled |
EffectPreRemoveEvent |
An effect is about to be deliberately removed |
EffectPostRemoveEvent |
An effect has been removed and its potion effect cleared |
EffectExpireEvent |
An effect's duration has elapsed |
EffectTickEvent |
Every tick, for each live holder that has not expired |
The three pre events are cancellable. Cancelling an add leaves the effect unapplied, cancelling an update means the consumer never runs, and cancelling a remove leaves the effect in place. EffectPreRemoveEvent only fires for a deliberate removal, so expiry, death, a disconnect and a conditional removal cannot be refused.
Expiry reports through EffectExpireEvent in place of EffectPostRemoveEvent, so a listener wanting both listens to each. EffectTickEvent fires for every holder of every effect on each pass, so a listener on it should stay proportionate to that.
| Event | Fired When |
|---|---|
ChatChannelEvent |
A message is about to be sent, to resolve which channel it goes in |
ChatSendEvent |
A message is about to be sent in its channel, before anyone receives it |
ChatReceiveEvent |
One recipient is about to receive a message |
ChatSwitchChannelEvent |
A player is switching channels, dispatched by the plugin |
All but ChatChannelEvent are cancellable. Cancelling a send refuses the message for everyone, cancelling a receive hides it from that recipient alone, and cancelling a switch keeps the player in their current channel. ChatChannelEvent is not, since every message needs a channel; the last listener to set one wins.
| Interface | Description |
|---|---|
SpigotPlugin |
Root plugin with automatic Bukkit registration callbacks |
Node |
Typed parent access for commands and subcommands (provided by Hierarchy-Framework) |
SharedBaseCommand |
Shared contract between commands and subcommands: sender validation, permission, execution, and tab-complete |
IBaseCommand |
Command contract with subcommand management |
ICustomCancellableEvent |
Cancellable event with reason support |
DeathEvent |
Shared contract of both death events: entity, killer, cause, reason, drops, experience and death sound |
InteractableNpc |
Opts an NPC into right-click handling, with its own gate |
DamageableNpc |
Opts an NPC into damage, death and respawn handling |
SystemTimeMixin |
Carries a timestamp of when something was created or started |
DurationMixin |
Carries a duration, with -1 meaning permanent |
ExpiredMixin |
Reports whether a duration has elapsed |
RemainingMixin |
Reports how much of a duration is left |