Skip to content

Custom types

Addon developer

AbstractMenus has five type registries. Each one exposes the same register(key, class, serializer, owner) shape:

api.actions().register("...", MyAction.class, new MyAction.Serializer(), this);
api.rules().register("...", MyRule.class, new MyRule.Serializer(), this);
api.itemProperties().register("...", MyProperty.class, new MyProperty.Serializer(), this);
api.activators().register("...", MyActivator.class, new MyActivator.Serializer(), this);
api.catalogs().register("...", MyCatalog.class, new MyCatalog.Serializer(), this);

Keys are case-insensitive. The owner is your MenuExtension instance — AbstractMenus uses it to drop your registrations when your addon disables.

Pick a vendor prefix for your keys (myaddon_action, playerpoints_take) so a future built-in named the same thing doesn’t collide.

All registration calls happen in MenuExtension.onEnable(api). Don’t register from onLoad — other addons that yours depends on may not have enabled yet.

An action is something the plugin does: send a message, give an item, run a command, open another menu. Each action class implements Action:

public class MessageAction implements Action {
private final String text;
private MessageAction(String text) {
this.text = text;
}
@Override
public void activate(Player player, Menu menu, Item clickedItem) {
player.sendMessage(text);
}
public static class Serializer implements NodeSerializer<MessageAction> {
@Override
public MessageAction deserialize(Class<MessageAction> type, ConfigNode node) {
return new MessageAction(node.getString());
}
}
}

clickedItem may be null — depends on what triggered the action chain (a click vs. e.g. a deny-action chain that fired before any item was selected).

Register it:

api.actions().register("myMessage", MessageAction.class, new MessageAction.Serializer(), this);

In a menu file:

items: [
{
slot: 1
material: STONE
name: "My item"
click {
myMessage: "Hello! This is my action!"
}
}
]

A rule is a boolean check evaluated against a player. It implements Rule:

public class IsBobRule implements Rule {
@Override
public boolean check(Player player, Menu menu, Item clickedItem) {
return "Bob".equals(player.getName());
}
public static class Serializer implements NodeSerializer<IsBobRule> {
@Override
public IsBobRule deserialize(Class<IsBobRule> type, ConfigNode node) {
return new IsBobRule();
}
}
}

Register and use:

api.rules().register("isBob", IsBobRule.class, new IsBobRule.Serializer(), this);
rules {
isBob: true
}

A rule with no parameters can take true (HOCON-shorthand for “this rule is active”) in the menu config. Rules with parameters take whatever HOCON shape your serializer reads.

A value extractor pulls named values out of a context object. Activators and catalogs use it to expose context data through placeholders.

It implements ValueExtractor:

public class UserExtractor implements ValueExtractor {
@Override
public String extract(Object obj, String placeholder) {
if (!(obj instanceof User)) return null;
User user = (User) obj;
return switch (placeholder) {
case "user_name" -> user.name;
case "user_age" -> String.valueOf(user.age);
case "user_friends" -> String.valueOf(user.friends);
default -> null;
};
}
}

Extractors are not registered with AbstractMenus directly. The activator or catalog that uses one returns the extractor instance from a getValueExtractor() / extractor() method.

The shape is similar to PlaceholderAPI but accepts any context type, not only Player.

An activator is an event listener that opens a menu. It extends the abstract Activator, which extends Bukkit’s Listener. Inside the class, listen for any Bukkit event you need:

public class SneakActivator extends Activator {
@EventHandler
public void onSneak(PlayerToggleSneakEvent event) {
if (event.isSneaking()) {
openMenu(null, event.getPlayer());
}
}
public static class Serializer implements NodeSerializer<SneakActivator> {
@Override
public SneakActivator deserialize(Class<SneakActivator> type, ConfigNode node) {
return new SneakActivator();
}
}
}

openMenu(ctx, player) opens the menu the activator is bound to. ctx is an opening context — null if you have nothing to attach.

Register the activator:

api.activators().register("onSneak", SneakActivator.class, new SneakActivator.Serializer(), this);

Pass any object as ctx and expose its values through a ValueExtractor. Below, the respawn location is the context, and LocationExtractor makes its coordinates available as %activator_loc_x% etc.:

public class RespawnActivator extends Activator {
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
openMenu(event.getRespawnLocation(), event.getPlayer());
}
@Override
public ValueExtractor getValueExtractor() {
return new LocationExtractor();
}
public static class Serializer implements NodeSerializer<RespawnActivator> {
@Override
public RespawnActivator deserialize(Class<RespawnActivator> type, ConfigNode node) {
return new RespawnActivator();
}
}
}
public class LocationExtractor implements ValueExtractor {
@Override
public String extract(Object obj, String placeholder) {
if (!(obj instanceof Location)) return null;
Location loc = (Location) obj;
return switch (placeholder) {
case "loc_x" -> String.valueOf(loc.getX());
case "loc_y" -> String.valueOf(loc.getY());
case "loc_z" -> String.valueOf(loc.getZ());
default -> null;
};
}
}
title: "Test"
size: 1
activators {
onRespawn: true
}
items: [
{
slot: 4
material: CAKE
name: "Test item"
lore: [
"Loc x: %activator_loc_x%",
"Loc y: %activator_loc_y%",
"Loc z: %activator_loc_z%"
]
}
]

An item property modifies an item’s appearance — display name, lore, material, custom model data, etc.

Implement ItemProperty:

  • canReplaceMaterial — returns true if the property changes the item’s material. Material-replacing properties run first so subsequent properties operate on a valid ItemMeta.
  • isApplyMeta — return true if AbstractMenus should call setItemMeta on the stack after apply returns. Return false if apply already handles meta itself.
  • apply — modifies the ItemStack and/or ItemMeta.

Display-name property:

public class DisplayNameProperty implements ItemProperty {
private final String name;
private DisplayNameProperty(String name) {
this.name = name;
}
@Override
public boolean canReplaceMaterial() { return false; }
@Override
public boolean isApplyMeta() { return true; }
@Override
public void apply(ItemStack item, ItemMeta meta, Player player, Menu menu) {
meta.setDisplayName(name);
}
public static class Serializer implements NodeSerializer<DisplayNameProperty> {
@Override
public DisplayNameProperty deserialize(Class<DisplayNameProperty> type, ConfigNode node) {
return new DisplayNameProperty(node.getString());
}
}
}

Material replacer (no params, just sets the type):

public class CreeperHeadProperty implements ItemProperty {
@Override
public boolean canReplaceMaterial() { return true; }
@Override
public boolean isApplyMeta() { return false; }
@Override
public void apply(ItemStack item, ItemMeta meta, Player player, Menu menu) {
item.setType(Material.CREEPER_HEAD);
}
public static class Serializer implements NodeSerializer<CreeperHeadProperty> {
@Override
public CreeperHeadProperty deserialize(Class<CreeperHeadProperty> type, ConfigNode node) {
return new CreeperHeadProperty();
}
}
}

Register:

api.itemProperties().register("creeperHead", CreeperHeadProperty.class, new CreeperHeadProperty.Serializer(), this);

A catalog provides a dynamic collection of objects to a generated menu. Each entry in the collection becomes one rendered item, and a ValueExtractor lets you read fields off each entry through placeholders.

The class implements Catalog<T>:

public class UserCatalog implements Catalog<User> {
@Override
public Collection<User> snapshot(Player player, Menu menu) {
return List.of(
new User("User 1", 17),
new User("User 2", 18),
new User("User 3", 19)
);
}
@Override
public ValueExtractor extractor() {
return new UserExtractor();
}
public static class Serializer implements NodeSerializer<UserCatalog> {
@Override
public UserCatalog deserialize(Class<UserCatalog> type, ConfigNode node) throws NodeSerializeException {
return new UserCatalog();
}
}
}

snapshot runs once per menu open (or per refresh). It can return an empty collection but never null.

Register and use:

api.catalogs().register("users", UserCatalog.class, new UserCatalog.Serializer(), this);
title: "Users"
size: 4
catalog {
type: users
}
matrix {
cells: [
"_x_x_x_x_",
"_x_x_x_x_",
"_x_x_x_x_"
]
templates {
"x" {
material: CAKE
name: "%activator_user_name%"
lore: "&7Age: &e%activator_user_age%"
}
}
}

If your catalog needs configuration (e.g. a filter), parse it from the ConfigNode in the serializer.

Type registrations are dropped automatically when your addon disables. AbstractMenus tracks the owner you passed to register(...) and wipes everything tagged with that owner. You don’t (and can’t) call unregister from your addon.