Skip to content

Repository files navigation

Core Framework

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok

Table of Contents


Overview

Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:

SubsystemWhat it gives you
DI ContainerConstructor, field, and method injection with singleton/prototype scopes
Class ScannerAutomatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui
Config FrameworkType-safe YAML/JSON configs as plain Java objects
Command FrameworkAnnotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed
Inventory FrameworkFluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation
Data StoreBinary, non-human-readable persistent key-value storage per plugin
Display SystemsTitle/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities)
NPC FrameworkCitizens-free NPC system with hologram labels, skin support, and click actions
Interaction SystemsChat input, multi-step forms, context menus, NPC conversation trees
Task PipelineFluent async/sync task chains with @Task annotation scheduling
Loot TablesWeighted, pool-based loot system with conditions and @LootTableDef auto-registration
Particle SystemTyped particle effects, geometric shapes, and animated sequences
PlaceholderAPISoft-dependency integration with auto-discovered PlaceholderProvider components
Item BuildersModular, typed fluent builders for every item meta variant
Hot-ReloadConfig + listener reload without restart via @Reloadable and /core reload
Debug Overlay/core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev
UtilitiesScheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions

Architecture

CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle

Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.


Getting Started

Adding Core as a Dependency

build.gradle.kts (your plugin):

repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}

plugin.yml:

depend:
- Core

That's it. Core handles all scanning, injection, and registration automatically.

Project Structure

Recommended package layout for a plugin using Core:

dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component

Your MyPlugin.java just needs to trigger Core's scanner:

publicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}

Dependency Injection

Registering Components

@ComponentpublicclassEconomyService {
publicvoidaddBalance(UUIDplayer, doubleamount) { ... }
}

The scanner discovers @Component classes automatically. You never call new EconomyService().

Injecting Dependencies

@ComponentpublicclassShopService {
// Field injection@InjectprivateEconomyServiceeconomyService;
// Constructor injection (preferred — makes dependencies explicit)@InjectpublicShopService(EconomyServiceeconomyService, MainConfigconfig) { ... }
// Method injection (called after field injection)@InjectpublicvoidsetConfig(MainConfigconfig) { ... }
}

Scopes

AnnotationBehavior
@Singleton (default)One shared instance for the entire container lifetime
@PrototypeNew instance created on every injection point

GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.

Named Qualifiers

container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject@Named("mysql") privateDataSourceprimaryDatabase;
@Inject@Named("redis") privateDataSourcecacheDatabase;

Lifecycle Callbacks

@ComponentpublicclassConnectionPool {
@PostConstruct// called after all @Inject fields are resolvedpublicvoidinit() { openConnections(); }
@PreDestroy// called before the container destroys this instancepublicvoidcleanup() { closeConnections(); }
}

Manual Binding

Containercontainer = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> newPdfReport(newFileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyServiceservice = container.resolve(EconomyService.class);

Config Framework

Creating a Config

@Config(value = "settings", format = ConfigFormat.YAML)
publicclassMainConfigextendsAbstractConfig {
publicStringprefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
publicbooleandebug = false;
publicintmaxHomes = 5;
publicList<String> worlds = List.of("world", "world_nether");
publicDatabaseSectiondatabase = newDatabaseSection();
publicstaticclassDatabaseSectionimplementsjava.io.Serializable {
publicStringhost = "localhost";
publicintport = 3306;
publicStringname = "myplugin";
}
@OverrideprotectedvoidonLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}

The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file does not exist.

AttributeDefaultDescription
valuerequiredFilename without extension
formatYAMLYAML or JSON
directory"" (root)Sub-directory within the data folder
autoSavetrueSave on plugin disable
copyDefaultstrueCopy from JAR resources if file missing

Accessing Configs

@InjectprivateMainConfigconfig;
// Or via managerMainConfigconfig = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);

Reloading

config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();

Formats

YAML (default):

prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] 'debug: falsemaxHomes: 5

JSON:

{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}

Command Framework

Root Commands

Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:

@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
publicclassHomeCommandextendsBaseCommand {
@InjectprivateHomeServicehomeService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}

Sub-Commands

@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
publicvoidonSet(CommandContextctx) {
finalStringname = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
publicvoidonDelete(CommandContextctx) { ... }
@SubCommand(value = "list", playerOnly = true)
publicvoidonList(CommandContextctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
publicvoidonTeleport(CommandContextctx) { ... }

Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.

CommandContext API

ctx.isPlayer();
ctx.player(); // Optional<Player>ctx.playerOrThrow(); // Player — throws if not playerctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>ctx.argInt(1); // Optional<Integer>ctx.argDouble(2); // Optional<Double>ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onwardctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");

Tab Completion

@OverrideprotectedList<String> onTabComplete(@NotNullCommandContextctx) {
if (ctx.argCount() == 1) returnhomeService.listHomes(ctx.playerOrThrow());
returnsuper.onTabComplete(ctx);
}
@OverrideprotectedList<String> onSubTabComplete(@NotNullCommandContextctx,
@NotNullSubCommandHandlerhandler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
returnhomeService.listHomes(ctx.playerOrThrow());
}
returnCollections.emptyList();
}

Cooldown System

Apply @Cooldown to any @Command class or @SubCommand method:

@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
publicvoidonHeal(CommandContextctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
publicvoidonDaily(CommandContextctx) { ... }
// Custom bypass permission@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
publicvoidonOther(CommandContextctx) { ... }
PlaceholderDescription
<remaining>Human-readable time remaining
<total>Total cooldown duration

Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:

CooldownManagercooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");

Inventory Framework

Creating a GUI

@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
publicclassMainMenuGuiextendsAbstractGui {
@InjectprivateHomeServicehomeService;
@Overrideprotectedvoidbuild(@NotNullGuiBuilderbuilder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@OverrideprotectedvoidonOpen(@NotNullPlayerplayer) { ... }
@OverrideprotectedvoidonClose(@NotNullPlayerplayer) { ... }
}

Opening a GUI

CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGuigui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);

Refreshing a GUI

CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);

GuiBuilder API

builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slot

Paged GUI

Extend PagedGui for automatic pagination:

@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
publicclassHomeListGuiextendsPagedGui {
@InjectprivateHomeServicehomeService;
@OverrideprotectedList<PagedItem> getItems() {
returnhomeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides@OverrideprotectedList<Integer> getContentSlots() { ... }
@OverrideprotectedvoiddecorateBackground(@NotNullGuiBuilderbuilder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@OverrideprotectedItemStackbuildPreviousButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildNextButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildPageIndicator(@NotNullPageContextctx) { ... }
}

For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():

@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
publicclassPlayerListGuiextendsSearchablePagedGui {
@OverrideprotectedList<PagedItem> getAllItems() { ... }
@OverrideprotectedvoidbuildControls(@NotNullGuiBuilderbuilder, @NotNullPageContextctx) {
super.buildControls(builder, ctx); // prev / indicator / nextbuilder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}

Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().


Data Store

Defining a Store

@DataStore(value = "playerdata", directory = "data")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}

Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.

CRUD Operations

store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>store.remove(uuid); // returns true if removedstore.getAll(); // Map<UUID, PlayerData>store.contains(uuid);
store.size();

TTL / Expiry

store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});

Custom Key Types

@DataStore("factiondata")
publicclassFactionDataStoreextendsAbstractDataStore<String, FactionData> {
publicFactionDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullStringkey) {
returnkey.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@OverrideprotectedStringfileNameToKey(@NotNullStringfileName) { returnfileName; }
}

Event Listeners

@Component@ListenerpublicclassPlayerJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateMainConfigconfig;
@EventHandler(priority = EventPriority.NORMAL)
publicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandlerpublicvoidonQuit(PlayerQuitEventevent) {
playerService.savePlayer(event.getPlayer());
}
}

The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.


Module System

Creating a Module

publicclassEconomyModuleextendsAbstractCoreModule {
privatefinalContainercontainer;
publicEconomyModule(Containercontainer) {
super("Economy");
this.container = container;
}
@OverrideprotectedvoidonLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@OverrideprotectedvoidonEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@OverrideprotectedvoidonDisable() {
container.resolve(TransactionLog.class).flush();
}
}

Registering Modules

core.getModuleRegistry().register(newEconomyModule(core.getContainer()));

Lifecycle Order

Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)

Item Builders

All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.

ItemBuilder

ItemStacksword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStackfiller = ItemBuilder.filler();
ItemStackredFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);

SkullBuilder

SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();

LeatherArmorBuilder

LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();

BookBuilder

BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();

FireworkBuilder

FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();

Display Systems

Title & Actionbar Manager

// Titles — fluent builderTitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcutsTitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwritingactionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = backgroundactionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);

Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.

Boss Bar Manager

// Countdown bar — progress automatically decreases to 0bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updatesbossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notificationbossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");

Scoreboard Framework

Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:

scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on joinscoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one playerscoreboardManager.hide(player);
// Dynamic titlescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific linescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));

Hologram Framework

Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:

// Text + item mixed hologramhologramManager.builder("shop_sign", location)
.item(newItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologramhologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboardhologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawnhologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5shologramManager.remove("shop_sign");
hologramManager.removeAll();

NPC Framework

Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:

npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(newLocation(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram updatenpcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();

Interaction Systems

Chat Input Handler

chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
caseCOMPLETED -> homeService.createHome(player, result.getValue());
caseCANCELLED -> player.sendMessage("<gray>Cancelled.");
caseTIMED_OUT -> player.sendMessage("<red>Too slow!");
caseDISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});

Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(), integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().

Form System

Multi-step sequential input forms:

Formform = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)formManager.open(form, player).thenAccept(response -> { ... });

FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(), isCancelled(), getCancelledAtField().

Menu System

Lightweight chat-based context menus — no inventory needed. Players click text or type a number:

ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);

Output in chat:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.

MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.

Conversation System

Branching NPC dialogue trees with conditions, actions, and player choice:

ConversationTreetree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC clickconversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});

Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions. ConversationContext provides key-value data store and flag system between nodes.


Task Pipeline

Fluent async/sync task chains with automatic thread-switching mid-chain:

taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main threadctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>

Guard clauses mid-chain:

.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
returnbalance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))

Scheduled tasks via @Task — auto-discovered on boot:

@ComponentpublicclassBackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
publicvoidrunBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
publicvoidstartupCheck() { database.verifySchema(); }
}

Manual scheduling:

taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();

Loot Table System

Weighted pool-based drop system with conditions and @LootTableDef auto-registration:

@ComponentpublicclassModLootTables {
@LootTableDefpublicLootTablezombieDrops() {
returnLootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(newItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(newItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDefpublicLootTablebossDrops() {
returnLootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(newItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}

Rolling API:

// Roll for a player (auto-detects looting enchantment level)lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world locationlootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifierLootContextctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);

Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(), hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().


Particle System

// One-shot static effectsParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with genericsParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effectsParticleAnimatorhelix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimatorpulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full controlnewParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();

Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.


PlaceholderAPI Integration

Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:

softdepend:
- PlaceholderAPI
// Option A — implement PlaceholderProvider on any @Component (auto-discovered)@ComponentpublicclassEconomyPlaceholdersimplementsPlaceholderProvider {
@InjectprivateEconomyServiceeconomy;
@OverridepublicvoidregisterPlaceholders(@NotNullPlaceholderRegistryregistry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registrationCorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a stringStringmsg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one callComponentcomp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");

Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%, %core_player_name%, %core_player_uuid%, %core_player_online%.

Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value), registerOnline() (null-safe player).


Update Checker

Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.

// One-liner — async, logs to console automaticallynewUpdateChecker(this).checkAsync();
// With callback (runs on main thread)newUpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)UpdateResultresult = newUpdateChecker(this).checkSync();

Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.


Hot-Reload

Reload configs and listeners without restarting the server. Triggered via /core reload.

// Annotate any @Component method — auto-discovered@ComponentpublicclassShopService {
@InjectprivateShopConfigconfig;
@Reloadable(name = "Shop Service", order = 20)
publicvoidreload() {
config.reload();
rebuildCache();
}
}
// Register a manual stephotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmaticallyReloadResultresult = hotReloadManager.reload(sender);
switch (result.getStatus()) {
caseSUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
casePARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
caseALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}

Reload phases (in order):

  1. Unregister all managed Bukkit listeners
  2. Execute built-in steps (Configs.reloadAll(), listener re-registration)
  3. Execute registered ReloadSteps
  4. Execute @Reloadable methods sorted by order
  5. Re-register all listeners
  6. Re-inject all singleton fields (new config values propagate automatically)

Debug Overlay

/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload

Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.

Add custom entries via @Debug on any @Component:

@Component@DebugpublicclassShopDebugInfo {
@InjectprivateShopServiceshopService;
@Debug(category = "Shop", label = "Active Listings")
publicStringlistings() {
returnString.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
publicStringrevenue() {
return"<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
publicMap<String, Integer> breakdown() {
returnshopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registrationcore.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());

The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and generator included.


Utilities

SchedulerUtil

SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTasktask = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticksSchedulerUtil.minutes(1); // → 1200 ticks

ComponentUtil

ComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");

ColorUtil

ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optionalColorUtil.fromHex("#00F"); // shorthand expands to #0000FFColorUtil.fromHexSafe("#invalid"); // returns null on failureColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);

TimeUtil

TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"TimeUtil.formatSeconds(45); // → "45s"TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)TimeUtil.parse("2d12h"); // → Duration.ofHours(60)TimeUtil.parseSafe("bad_input"); // → Duration.ZEROTimeUtil.toTicks(Duration.ofSeconds(5)); // → 100TimeUtil.fromTicks(200); // → 10 seconds

SoundUtil

// Presets — zero allocation, all static finalSoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effectSoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequencesSoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);

Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE, OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT, WITHDRAW.

Preconditions

Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");

Annotation Reference

AnnotationTargetPurpose
@ComponentClassRegister as DI-managed component
@SingletonClassExplicit singleton scope (default)
@PrototypeClassNew instance per injection point
@InjectField / Constructor / MethodMark injection point
@Named("id")Field / ParameterQualify injection by name
@PostConstructMethodCalled after all fields injected
@PreDestroyMethodCalled before container destroys instance
@Config(...)ClassDeclare a config file binding
@Command(...)ClassRegister a command handler
@SubCommand(...)MethodRegister a sub-command on a BaseCommand
@Cooldown(...)Method / ClassApply cooldown to a command or sub-command
@ListenerClassAuto-register as Bukkit event listener
@DataStore(...)ClassRegister a binary data store
@InventoryGui(...)ClassRegister a GUI inventory
@Task(...)MethodSchedule a recurring or one-shot task
@Reloadable(...)MethodInvoked during hot-reload
@Debug(...)Method / ClassExpose info to /core debug
@LootTableDefMethodRegister a LootTable factory method

Exception Hierarchy

CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open

All exceptions are unchecked and carry a descriptive message including the failing component name.


Boot Sequence

onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)

Full Example Plugin

// MyPlugin.javapublicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java@Config("config")
publicclassExampleConfigextendsAbstractConfig {
publicStringwelcomeMessage = "<green>Welcome, <player>!";
publicintstartBalance = 100;
}
// PlayerDataStore.java@DataStore("players")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}
// PlayerService.java@ComponentpublicclassPlayerService {
@InjectprivatePlayerDataStorestore;
@InjectprivateExampleConfigconfig;
publicvoidonJoin(Playerplayer) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
newPlayerData(player.getName(), config.startBalance));
}
}
publicintgetBalance(UUIDuuid) {
returnstore.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
publicvoidreload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java@Component@ListenerpublicclassJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateExampleConfigconfig;
@EventHandlerpublicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
publicclassBalanceCommandextendsBaseCommand {
@InjectprivatePlayerServiceplayerService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
finalintbalance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
publicvoidonSet(CommandContextctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}

License

MIT License — Copyright (c) 2026 mzcy_ and contributors.

Built with ❤ for the Paper ecosystem.

About

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - mzcydev/paper-core: A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle. · GitHub
Skip to content

Repository files navigation

Core Framework

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok

Table of Contents


Overview

Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:

SubsystemWhat it gives you
DI ContainerConstructor, field, and method injection with singleton/prototype scopes
Class ScannerAutomatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui
Config FrameworkType-safe YAML/JSON configs as plain Java objects
Command FrameworkAnnotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed
Inventory FrameworkFluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation
Data StoreBinary, non-human-readable persistent key-value storage per plugin
Display SystemsTitle/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities)
NPC FrameworkCitizens-free NPC system with hologram labels, skin support, and click actions
Interaction SystemsChat input, multi-step forms, context menus, NPC conversation trees
Task PipelineFluent async/sync task chains with @Task annotation scheduling
Loot TablesWeighted, pool-based loot system with conditions and @LootTableDef auto-registration
Particle SystemTyped particle effects, geometric shapes, and animated sequences
PlaceholderAPISoft-dependency integration with auto-discovered PlaceholderProvider components
Item BuildersModular, typed fluent builders for every item meta variant
Hot-ReloadConfig + listener reload without restart via @Reloadable and /core reload
Debug Overlay/core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev
UtilitiesScheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions

Architecture

CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle

Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.


Getting Started

Adding Core as a Dependency

build.gradle.kts (your plugin):

repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}

plugin.yml:

depend:
- Core

That's it. Core handles all scanning, injection, and registration automatically.

Project Structure

Recommended package layout for a plugin using Core:

dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component

Your MyPlugin.java just needs to trigger Core's scanner:

publicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}

Dependency Injection

Registering Components

@ComponentpublicclassEconomyService {
publicvoidaddBalance(UUIDplayer, doubleamount) { ... }
}

The scanner discovers @Component classes automatically. You never call new EconomyService().

Injecting Dependencies

@ComponentpublicclassShopService {
// Field injection@InjectprivateEconomyServiceeconomyService;
// Constructor injection (preferred — makes dependencies explicit)@InjectpublicShopService(EconomyServiceeconomyService, MainConfigconfig) { ... }
// Method injection (called after field injection)@InjectpublicvoidsetConfig(MainConfigconfig) { ... }
}

Scopes

AnnotationBehavior
@Singleton (default)One shared instance for the entire container lifetime
@PrototypeNew instance created on every injection point

GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.

Named Qualifiers

container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject@Named("mysql") privateDataSourceprimaryDatabase;
@Inject@Named("redis") privateDataSourcecacheDatabase;

Lifecycle Callbacks

@ComponentpublicclassConnectionPool {
@PostConstruct// called after all @Inject fields are resolvedpublicvoidinit() { openConnections(); }
@PreDestroy// called before the container destroys this instancepublicvoidcleanup() { closeConnections(); }
}

Manual Binding

Containercontainer = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> newPdfReport(newFileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyServiceservice = container.resolve(EconomyService.class);

Config Framework

Creating a Config

@Config(value = "settings", format = ConfigFormat.YAML)
publicclassMainConfigextendsAbstractConfig {
publicStringprefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
publicbooleandebug = false;
publicintmaxHomes = 5;
publicList<String> worlds = List.of("world", "world_nether");
publicDatabaseSectiondatabase = newDatabaseSection();
publicstaticclassDatabaseSectionimplementsjava.io.Serializable {
publicStringhost = "localhost";
publicintport = 3306;
publicStringname = "myplugin";
}
@OverrideprotectedvoidonLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}

The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file does not exist.

AttributeDefaultDescription
valuerequiredFilename without extension
formatYAMLYAML or JSON
directory"" (root)Sub-directory within the data folder
autoSavetrueSave on plugin disable
copyDefaultstrueCopy from JAR resources if file missing

Accessing Configs

@InjectprivateMainConfigconfig;
// Or via managerMainConfigconfig = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);

Reloading

config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();

Formats

YAML (default):

prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] 'debug: falsemaxHomes: 5

JSON:

{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}

Command Framework

Root Commands

Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:

@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
publicclassHomeCommandextendsBaseCommand {
@InjectprivateHomeServicehomeService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}

Sub-Commands

@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
publicvoidonSet(CommandContextctx) {
finalStringname = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
publicvoidonDelete(CommandContextctx) { ... }
@SubCommand(value = "list", playerOnly = true)
publicvoidonList(CommandContextctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
publicvoidonTeleport(CommandContextctx) { ... }

Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.

CommandContext API

ctx.isPlayer();
ctx.player(); // Optional<Player>ctx.playerOrThrow(); // Player — throws if not playerctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>ctx.argInt(1); // Optional<Integer>ctx.argDouble(2); // Optional<Double>ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onwardctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");

Tab Completion

@OverrideprotectedList<String> onTabComplete(@NotNullCommandContextctx) {
if (ctx.argCount() == 1) returnhomeService.listHomes(ctx.playerOrThrow());
returnsuper.onTabComplete(ctx);
}
@OverrideprotectedList<String> onSubTabComplete(@NotNullCommandContextctx,
@NotNullSubCommandHandlerhandler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
returnhomeService.listHomes(ctx.playerOrThrow());
}
returnCollections.emptyList();
}

Cooldown System

Apply @Cooldown to any @Command class or @SubCommand method:

@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
publicvoidonHeal(CommandContextctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
publicvoidonDaily(CommandContextctx) { ... }
// Custom bypass permission@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
publicvoidonOther(CommandContextctx) { ... }
PlaceholderDescription
<remaining>Human-readable time remaining
<total>Total cooldown duration

Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:

CooldownManagercooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");

Inventory Framework

Creating a GUI

@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
publicclassMainMenuGuiextendsAbstractGui {
@InjectprivateHomeServicehomeService;
@Overrideprotectedvoidbuild(@NotNullGuiBuilderbuilder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@OverrideprotectedvoidonOpen(@NotNullPlayerplayer) { ... }
@OverrideprotectedvoidonClose(@NotNullPlayerplayer) { ... }
}

Opening a GUI

CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGuigui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);

Refreshing a GUI

CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);

GuiBuilder API

builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slot

Paged GUI

Extend PagedGui for automatic pagination:

@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
publicclassHomeListGuiextendsPagedGui {
@InjectprivateHomeServicehomeService;
@OverrideprotectedList<PagedItem> getItems() {
returnhomeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides@OverrideprotectedList<Integer> getContentSlots() { ... }
@OverrideprotectedvoiddecorateBackground(@NotNullGuiBuilderbuilder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@OverrideprotectedItemStackbuildPreviousButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildNextButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildPageIndicator(@NotNullPageContextctx) { ... }
}

For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():

@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
publicclassPlayerListGuiextendsSearchablePagedGui {
@OverrideprotectedList<PagedItem> getAllItems() { ... }
@OverrideprotectedvoidbuildControls(@NotNullGuiBuilderbuilder, @NotNullPageContextctx) {
super.buildControls(builder, ctx); // prev / indicator / nextbuilder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}

Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().


Data Store

Defining a Store

@DataStore(value = "playerdata", directory = "data")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}

Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.

CRUD Operations

store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>store.remove(uuid); // returns true if removedstore.getAll(); // Map<UUID, PlayerData>store.contains(uuid);
store.size();

TTL / Expiry

store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});

Custom Key Types

@DataStore("factiondata")
publicclassFactionDataStoreextendsAbstractDataStore<String, FactionData> {
publicFactionDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullStringkey) {
returnkey.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@OverrideprotectedStringfileNameToKey(@NotNullStringfileName) { returnfileName; }
}

Event Listeners

@Component@ListenerpublicclassPlayerJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateMainConfigconfig;
@EventHandler(priority = EventPriority.NORMAL)
publicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandlerpublicvoidonQuit(PlayerQuitEventevent) {
playerService.savePlayer(event.getPlayer());
}
}

The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.


Module System

Creating a Module

publicclassEconomyModuleextendsAbstractCoreModule {
privatefinalContainercontainer;
publicEconomyModule(Containercontainer) {
super("Economy");
this.container = container;
}
@OverrideprotectedvoidonLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@OverrideprotectedvoidonEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@OverrideprotectedvoidonDisable() {
container.resolve(TransactionLog.class).flush();
}
}

Registering Modules

core.getModuleRegistry().register(newEconomyModule(core.getContainer()));

Lifecycle Order

Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)

Item Builders

All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.

ItemBuilder

ItemStacksword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStackfiller = ItemBuilder.filler();
ItemStackredFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);

SkullBuilder

SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();

LeatherArmorBuilder

LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();

BookBuilder

BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();

FireworkBuilder

FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();

Display Systems

Title & Actionbar Manager

// Titles — fluent builderTitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcutsTitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwritingactionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = backgroundactionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);

Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.

Boss Bar Manager

// Countdown bar — progress automatically decreases to 0bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updatesbossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notificationbossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");

Scoreboard Framework

Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:

scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on joinscoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one playerscoreboardManager.hide(player);
// Dynamic titlescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific linescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));

Hologram Framework

Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:

// Text + item mixed hologramhologramManager.builder("shop_sign", location)
.item(newItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologramhologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboardhologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawnhologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5shologramManager.remove("shop_sign");
hologramManager.removeAll();

NPC Framework

Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:

npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(newLocation(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram updatenpcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();

Interaction Systems

Chat Input Handler

chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
caseCOMPLETED -> homeService.createHome(player, result.getValue());
caseCANCELLED -> player.sendMessage("<gray>Cancelled.");
caseTIMED_OUT -> player.sendMessage("<red>Too slow!");
caseDISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});

Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(), integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().

Form System

Multi-step sequential input forms:

Formform = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)formManager.open(form, player).thenAccept(response -> { ... });

FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(), isCancelled(), getCancelledAtField().

Menu System

Lightweight chat-based context menus — no inventory needed. Players click text or type a number:

ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);

Output in chat:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.

MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.

Conversation System

Branching NPC dialogue trees with conditions, actions, and player choice:

ConversationTreetree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC clickconversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});

Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions. ConversationContext provides key-value data store and flag system between nodes.


Task Pipeline

Fluent async/sync task chains with automatic thread-switching mid-chain:

taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main threadctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>

Guard clauses mid-chain:

.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
returnbalance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))

Scheduled tasks via @Task — auto-discovered on boot:

@ComponentpublicclassBackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
publicvoidrunBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
publicvoidstartupCheck() { database.verifySchema(); }
}

Manual scheduling:

taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();

Loot Table System

Weighted pool-based drop system with conditions and @LootTableDef auto-registration:

@ComponentpublicclassModLootTables {
@LootTableDefpublicLootTablezombieDrops() {
returnLootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(newItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(newItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDefpublicLootTablebossDrops() {
returnLootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(newItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}

Rolling API:

// Roll for a player (auto-detects looting enchantment level)lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world locationlootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifierLootContextctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);

Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(), hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().


Particle System

// One-shot static effectsParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with genericsParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effectsParticleAnimatorhelix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimatorpulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full controlnewParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();

Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.


PlaceholderAPI Integration

Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:

softdepend:
- PlaceholderAPI
// Option A — implement PlaceholderProvider on any @Component (auto-discovered)@ComponentpublicclassEconomyPlaceholdersimplementsPlaceholderProvider {
@InjectprivateEconomyServiceeconomy;
@OverridepublicvoidregisterPlaceholders(@NotNullPlaceholderRegistryregistry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registrationCorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a stringStringmsg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one callComponentcomp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");

Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%, %core_player_name%, %core_player_uuid%, %core_player_online%.

Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value), registerOnline() (null-safe player).


Update Checker

Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.

// One-liner — async, logs to console automaticallynewUpdateChecker(this).checkAsync();
// With callback (runs on main thread)newUpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)UpdateResultresult = newUpdateChecker(this).checkSync();

Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.


Hot-Reload

Reload configs and listeners without restarting the server. Triggered via /core reload.

// Annotate any @Component method — auto-discovered@ComponentpublicclassShopService {
@InjectprivateShopConfigconfig;
@Reloadable(name = "Shop Service", order = 20)
publicvoidreload() {
config.reload();
rebuildCache();
}
}
// Register a manual stephotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmaticallyReloadResultresult = hotReloadManager.reload(sender);
switch (result.getStatus()) {
caseSUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
casePARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
caseALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}

Reload phases (in order):

  1. Unregister all managed Bukkit listeners
  2. Execute built-in steps (Configs.reloadAll(), listener re-registration)
  3. Execute registered ReloadSteps
  4. Execute @Reloadable methods sorted by order
  5. Re-register all listeners
  6. Re-inject all singleton fields (new config values propagate automatically)

Debug Overlay

/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload

Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.

Add custom entries via @Debug on any @Component:

@Component@DebugpublicclassShopDebugInfo {
@InjectprivateShopServiceshopService;
@Debug(category = "Shop", label = "Active Listings")
publicStringlistings() {
returnString.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
publicStringrevenue() {
return"<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
publicMap<String, Integer> breakdown() {
returnshopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registrationcore.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());

The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and generator included.


Utilities

SchedulerUtil

SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTasktask = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticksSchedulerUtil.minutes(1); // → 1200 ticks

ComponentUtil

ComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");

ColorUtil

ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optionalColorUtil.fromHex("#00F"); // shorthand expands to #0000FFColorUtil.fromHexSafe("#invalid"); // returns null on failureColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);

TimeUtil

TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"TimeUtil.formatSeconds(45); // → "45s"TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)TimeUtil.parse("2d12h"); // → Duration.ofHours(60)TimeUtil.parseSafe("bad_input"); // → Duration.ZEROTimeUtil.toTicks(Duration.ofSeconds(5)); // → 100TimeUtil.fromTicks(200); // → 10 seconds

SoundUtil

// Presets — zero allocation, all static finalSoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effectSoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequencesSoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);

Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE, OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT, WITHDRAW.

Preconditions

Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");

Annotation Reference

AnnotationTargetPurpose
@ComponentClassRegister as DI-managed component
@SingletonClassExplicit singleton scope (default)
@PrototypeClassNew instance per injection point
@InjectField / Constructor / MethodMark injection point
@Named("id")Field / ParameterQualify injection by name
@PostConstructMethodCalled after all fields injected
@PreDestroyMethodCalled before container destroys instance
@Config(...)ClassDeclare a config file binding
@Command(...)ClassRegister a command handler
@SubCommand(...)MethodRegister a sub-command on a BaseCommand
@Cooldown(...)Method / ClassApply cooldown to a command or sub-command
@ListenerClassAuto-register as Bukkit event listener
@DataStore(...)ClassRegister a binary data store
@InventoryGui(...)ClassRegister a GUI inventory
@Task(...)MethodSchedule a recurring or one-shot task
@Reloadable(...)MethodInvoked during hot-reload
@Debug(...)Method / ClassExpose info to /core debug
@LootTableDefMethodRegister a LootTable factory method

Exception Hierarchy

CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open

All exceptions are unchecked and carry a descriptive message including the failing component name.


Boot Sequence

onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)

Full Example Plugin

// MyPlugin.javapublicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java@Config("config")
publicclassExampleConfigextendsAbstractConfig {
publicStringwelcomeMessage = "<green>Welcome, <player>!";
publicintstartBalance = 100;
}
// PlayerDataStore.java@DataStore("players")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}
// PlayerService.java@ComponentpublicclassPlayerService {
@InjectprivatePlayerDataStorestore;
@InjectprivateExampleConfigconfig;
publicvoidonJoin(Playerplayer) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
newPlayerData(player.getName(), config.startBalance));
}
}
publicintgetBalance(UUIDuuid) {
returnstore.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
publicvoidreload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java@Component@ListenerpublicclassJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateExampleConfigconfig;
@EventHandlerpublicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
publicclassBalanceCommandextendsBaseCommand {
@InjectprivatePlayerServiceplayerService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
finalintbalance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
publicvoidonSet(CommandContextctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}

License

MIT License — Copyright (c) 2026 mzcy_ and contributors.

Built with ❤ for the Paper ecosystem.

About

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - mzcydev/paper-core: A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle. · GitHub
Skip to content

Repository files navigation

Core Framework

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok

Table of Contents


Overview

Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:

SubsystemWhat it gives you
DI ContainerConstructor, field, and method injection with singleton/prototype scopes
Class ScannerAutomatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui
Config FrameworkType-safe YAML/JSON configs as plain Java objects
Command FrameworkAnnotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed
Inventory FrameworkFluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation
Data StoreBinary, non-human-readable persistent key-value storage per plugin
Display SystemsTitle/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities)
NPC FrameworkCitizens-free NPC system with hologram labels, skin support, and click actions
Interaction SystemsChat input, multi-step forms, context menus, NPC conversation trees
Task PipelineFluent async/sync task chains with @Task annotation scheduling
Loot TablesWeighted, pool-based loot system with conditions and @LootTableDef auto-registration
Particle SystemTyped particle effects, geometric shapes, and animated sequences
PlaceholderAPISoft-dependency integration with auto-discovered PlaceholderProvider components
Item BuildersModular, typed fluent builders for every item meta variant
Hot-ReloadConfig + listener reload without restart via @Reloadable and /core reload
Debug Overlay/core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev
UtilitiesScheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions

Architecture

CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle

Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.


Getting Started

Adding Core as a Dependency

build.gradle.kts (your plugin):

repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}

plugin.yml:

depend:
- Core

That's it. Core handles all scanning, injection, and registration automatically.

Project Structure

Recommended package layout for a plugin using Core:

dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component

Your MyPlugin.java just needs to trigger Core's scanner:

publicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}

Dependency Injection

Registering Components

@ComponentpublicclassEconomyService {
publicvoidaddBalance(UUIDplayer, doubleamount) { ... }
}

The scanner discovers @Component classes automatically. You never call new EconomyService().

Injecting Dependencies

@ComponentpublicclassShopService {
// Field injection@InjectprivateEconomyServiceeconomyService;
// Constructor injection (preferred — makes dependencies explicit)@InjectpublicShopService(EconomyServiceeconomyService, MainConfigconfig) { ... }
// Method injection (called after field injection)@InjectpublicvoidsetConfig(MainConfigconfig) { ... }
}

Scopes

AnnotationBehavior
@Singleton (default)One shared instance for the entire container lifetime
@PrototypeNew instance created on every injection point

GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.

Named Qualifiers

container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject@Named("mysql") privateDataSourceprimaryDatabase;
@Inject@Named("redis") privateDataSourcecacheDatabase;

Lifecycle Callbacks

@ComponentpublicclassConnectionPool {
@PostConstruct// called after all @Inject fields are resolvedpublicvoidinit() { openConnections(); }
@PreDestroy// called before the container destroys this instancepublicvoidcleanup() { closeConnections(); }
}

Manual Binding

Containercontainer = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> newPdfReport(newFileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyServiceservice = container.resolve(EconomyService.class);

Config Framework

Creating a Config

@Config(value = "settings", format = ConfigFormat.YAML)
publicclassMainConfigextendsAbstractConfig {
publicStringprefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
publicbooleandebug = false;
publicintmaxHomes = 5;
publicList<String> worlds = List.of("world", "world_nether");
publicDatabaseSectiondatabase = newDatabaseSection();
publicstaticclassDatabaseSectionimplementsjava.io.Serializable {
publicStringhost = "localhost";
publicintport = 3306;
publicStringname = "myplugin";
}
@OverrideprotectedvoidonLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}

The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file does not exist.

AttributeDefaultDescription
valuerequiredFilename without extension
formatYAMLYAML or JSON
directory"" (root)Sub-directory within the data folder
autoSavetrueSave on plugin disable
copyDefaultstrueCopy from JAR resources if file missing

Accessing Configs

@InjectprivateMainConfigconfig;
// Or via managerMainConfigconfig = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);

Reloading

config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();

Formats

YAML (default):

prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] 'debug: falsemaxHomes: 5

JSON:

{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}

Command Framework

Root Commands

Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:

@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
publicclassHomeCommandextendsBaseCommand {
@InjectprivateHomeServicehomeService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}

Sub-Commands

@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
publicvoidonSet(CommandContextctx) {
finalStringname = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
publicvoidonDelete(CommandContextctx) { ... }
@SubCommand(value = "list", playerOnly = true)
publicvoidonList(CommandContextctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
publicvoidonTeleport(CommandContextctx) { ... }

Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.

CommandContext API

ctx.isPlayer();
ctx.player(); // Optional<Player>ctx.playerOrThrow(); // Player — throws if not playerctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>ctx.argInt(1); // Optional<Integer>ctx.argDouble(2); // Optional<Double>ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onwardctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");

Tab Completion

@OverrideprotectedList<String> onTabComplete(@NotNullCommandContextctx) {
if (ctx.argCount() == 1) returnhomeService.listHomes(ctx.playerOrThrow());
returnsuper.onTabComplete(ctx);
}
@OverrideprotectedList<String> onSubTabComplete(@NotNullCommandContextctx,
@NotNullSubCommandHandlerhandler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
returnhomeService.listHomes(ctx.playerOrThrow());
}
returnCollections.emptyList();
}

Cooldown System

Apply @Cooldown to any @Command class or @SubCommand method:

@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
publicvoidonHeal(CommandContextctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
publicvoidonDaily(CommandContextctx) { ... }
// Custom bypass permission@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
publicvoidonOther(CommandContextctx) { ... }
PlaceholderDescription
<remaining>Human-readable time remaining
<total>Total cooldown duration

Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:

CooldownManagercooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");

Inventory Framework

Creating a GUI

@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
publicclassMainMenuGuiextendsAbstractGui {
@InjectprivateHomeServicehomeService;
@Overrideprotectedvoidbuild(@NotNullGuiBuilderbuilder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@OverrideprotectedvoidonOpen(@NotNullPlayerplayer) { ... }
@OverrideprotectedvoidonClose(@NotNullPlayerplayer) { ... }
}

Opening a GUI

CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGuigui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);

Refreshing a GUI

CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);

GuiBuilder API

builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slot

Paged GUI

Extend PagedGui for automatic pagination:

@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
publicclassHomeListGuiextendsPagedGui {
@InjectprivateHomeServicehomeService;
@OverrideprotectedList<PagedItem> getItems() {
returnhomeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides@OverrideprotectedList<Integer> getContentSlots() { ... }
@OverrideprotectedvoiddecorateBackground(@NotNullGuiBuilderbuilder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@OverrideprotectedItemStackbuildPreviousButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildNextButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildPageIndicator(@NotNullPageContextctx) { ... }
}

For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():

@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
publicclassPlayerListGuiextendsSearchablePagedGui {
@OverrideprotectedList<PagedItem> getAllItems() { ... }
@OverrideprotectedvoidbuildControls(@NotNullGuiBuilderbuilder, @NotNullPageContextctx) {
super.buildControls(builder, ctx); // prev / indicator / nextbuilder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}

Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().


Data Store

Defining a Store

@DataStore(value = "playerdata", directory = "data")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}

Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.

CRUD Operations

store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>store.remove(uuid); // returns true if removedstore.getAll(); // Map<UUID, PlayerData>store.contains(uuid);
store.size();

TTL / Expiry

store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});

Custom Key Types

@DataStore("factiondata")
publicclassFactionDataStoreextendsAbstractDataStore<String, FactionData> {
publicFactionDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullStringkey) {
returnkey.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@OverrideprotectedStringfileNameToKey(@NotNullStringfileName) { returnfileName; }
}

Event Listeners

@Component@ListenerpublicclassPlayerJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateMainConfigconfig;
@EventHandler(priority = EventPriority.NORMAL)
publicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandlerpublicvoidonQuit(PlayerQuitEventevent) {
playerService.savePlayer(event.getPlayer());
}
}

The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.


Module System

Creating a Module

publicclassEconomyModuleextendsAbstractCoreModule {
privatefinalContainercontainer;
publicEconomyModule(Containercontainer) {
super("Economy");
this.container = container;
}
@OverrideprotectedvoidonLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@OverrideprotectedvoidonEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@OverrideprotectedvoidonDisable() {
container.resolve(TransactionLog.class).flush();
}
}

Registering Modules

core.getModuleRegistry().register(newEconomyModule(core.getContainer()));

Lifecycle Order

Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)

Item Builders

All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.

ItemBuilder

ItemStacksword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStackfiller = ItemBuilder.filler();
ItemStackredFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);

SkullBuilder

SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();

LeatherArmorBuilder

LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();

BookBuilder

BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();

FireworkBuilder

FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();

Display Systems

Title & Actionbar Manager

// Titles — fluent builderTitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcutsTitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwritingactionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = backgroundactionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);

Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.

Boss Bar Manager

// Countdown bar — progress automatically decreases to 0bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updatesbossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notificationbossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");

Scoreboard Framework

Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:

scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on joinscoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one playerscoreboardManager.hide(player);
// Dynamic titlescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific linescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));

Hologram Framework

Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:

// Text + item mixed hologramhologramManager.builder("shop_sign", location)
.item(newItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologramhologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboardhologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawnhologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5shologramManager.remove("shop_sign");
hologramManager.removeAll();

NPC Framework

Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:

npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(newLocation(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram updatenpcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();

Interaction Systems

Chat Input Handler

chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
caseCOMPLETED -> homeService.createHome(player, result.getValue());
caseCANCELLED -> player.sendMessage("<gray>Cancelled.");
caseTIMED_OUT -> player.sendMessage("<red>Too slow!");
caseDISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});

Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(), integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().

Form System

Multi-step sequential input forms:

Formform = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)formManager.open(form, player).thenAccept(response -> { ... });

FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(), isCancelled(), getCancelledAtField().

Menu System

Lightweight chat-based context menus — no inventory needed. Players click text or type a number:

ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);

Output in chat:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.

MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.

Conversation System

Branching NPC dialogue trees with conditions, actions, and player choice:

ConversationTreetree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC clickconversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});

Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions. ConversationContext provides key-value data store and flag system between nodes.


Task Pipeline

Fluent async/sync task chains with automatic thread-switching mid-chain:

taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main threadctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>

Guard clauses mid-chain:

.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
returnbalance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))

Scheduled tasks via @Task — auto-discovered on boot:

@ComponentpublicclassBackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
publicvoidrunBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
publicvoidstartupCheck() { database.verifySchema(); }
}

Manual scheduling:

taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();

Loot Table System

Weighted pool-based drop system with conditions and @LootTableDef auto-registration:

@ComponentpublicclassModLootTables {
@LootTableDefpublicLootTablezombieDrops() {
returnLootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(newItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(newItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDefpublicLootTablebossDrops() {
returnLootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(newItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}

Rolling API:

// Roll for a player (auto-detects looting enchantment level)lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world locationlootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifierLootContextctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);

Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(), hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().


Particle System

// One-shot static effectsParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with genericsParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effectsParticleAnimatorhelix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimatorpulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full controlnewParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();

Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.


PlaceholderAPI Integration

Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:

softdepend:
- PlaceholderAPI
// Option A — implement PlaceholderProvider on any @Component (auto-discovered)@ComponentpublicclassEconomyPlaceholdersimplementsPlaceholderProvider {
@InjectprivateEconomyServiceeconomy;
@OverridepublicvoidregisterPlaceholders(@NotNullPlaceholderRegistryregistry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registrationCorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a stringStringmsg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one callComponentcomp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");

Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%, %core_player_name%, %core_player_uuid%, %core_player_online%.

Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value), registerOnline() (null-safe player).


Update Checker

Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.

// One-liner — async, logs to console automaticallynewUpdateChecker(this).checkAsync();
// With callback (runs on main thread)newUpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)UpdateResultresult = newUpdateChecker(this).checkSync();

Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.


Hot-Reload

Reload configs and listeners without restarting the server. Triggered via /core reload.

// Annotate any @Component method — auto-discovered@ComponentpublicclassShopService {
@InjectprivateShopConfigconfig;
@Reloadable(name = "Shop Service", order = 20)
publicvoidreload() {
config.reload();
rebuildCache();
}
}
// Register a manual stephotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmaticallyReloadResultresult = hotReloadManager.reload(sender);
switch (result.getStatus()) {
caseSUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
casePARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
caseALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}

Reload phases (in order):

  1. Unregister all managed Bukkit listeners
  2. Execute built-in steps (Configs.reloadAll(), listener re-registration)
  3. Execute registered ReloadSteps
  4. Execute @Reloadable methods sorted by order
  5. Re-register all listeners
  6. Re-inject all singleton fields (new config values propagate automatically)

Debug Overlay

/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload

Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.

Add custom entries via @Debug on any @Component:

@Component@DebugpublicclassShopDebugInfo {
@InjectprivateShopServiceshopService;
@Debug(category = "Shop", label = "Active Listings")
publicStringlistings() {
returnString.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
publicStringrevenue() {
return"<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
publicMap<String, Integer> breakdown() {
returnshopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registrationcore.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());

The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and generator included.


Utilities

SchedulerUtil

SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTasktask = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticksSchedulerUtil.minutes(1); // → 1200 ticks

ComponentUtil

ComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");

ColorUtil

ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optionalColorUtil.fromHex("#00F"); // shorthand expands to #0000FFColorUtil.fromHexSafe("#invalid"); // returns null on failureColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);

TimeUtil

TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"TimeUtil.formatSeconds(45); // → "45s"TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)TimeUtil.parse("2d12h"); // → Duration.ofHours(60)TimeUtil.parseSafe("bad_input"); // → Duration.ZEROTimeUtil.toTicks(Duration.ofSeconds(5)); // → 100TimeUtil.fromTicks(200); // → 10 seconds

SoundUtil

// Presets — zero allocation, all static finalSoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effectSoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequencesSoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);

Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE, OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT, WITHDRAW.

Preconditions

Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");

Annotation Reference

AnnotationTargetPurpose
@ComponentClassRegister as DI-managed component
@SingletonClassExplicit singleton scope (default)
@PrototypeClassNew instance per injection point
@InjectField / Constructor / MethodMark injection point
@Named("id")Field / ParameterQualify injection by name
@PostConstructMethodCalled after all fields injected
@PreDestroyMethodCalled before container destroys instance
@Config(...)ClassDeclare a config file binding
@Command(...)ClassRegister a command handler
@SubCommand(...)MethodRegister a sub-command on a BaseCommand
@Cooldown(...)Method / ClassApply cooldown to a command or sub-command
@ListenerClassAuto-register as Bukkit event listener
@DataStore(...)ClassRegister a binary data store
@InventoryGui(...)ClassRegister a GUI inventory
@Task(...)MethodSchedule a recurring or one-shot task
@Reloadable(...)MethodInvoked during hot-reload
@Debug(...)Method / ClassExpose info to /core debug
@LootTableDefMethodRegister a LootTable factory method

Exception Hierarchy

CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open

All exceptions are unchecked and carry a descriptive message including the failing component name.


Boot Sequence

onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)

Full Example Plugin

// MyPlugin.javapublicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java@Config("config")
publicclassExampleConfigextendsAbstractConfig {
publicStringwelcomeMessage = "<green>Welcome, <player>!";
publicintstartBalance = 100;
}
// PlayerDataStore.java@DataStore("players")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}
// PlayerService.java@ComponentpublicclassPlayerService {
@InjectprivatePlayerDataStorestore;
@InjectprivateExampleConfigconfig;
publicvoidonJoin(Playerplayer) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
newPlayerData(player.getName(), config.startBalance));
}
}
publicintgetBalance(UUIDuuid) {
returnstore.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
publicvoidreload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java@Component@ListenerpublicclassJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateExampleConfigconfig;
@EventHandlerpublicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
publicclassBalanceCommandextendsBaseCommand {
@InjectprivatePlayerServiceplayerService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
finalintbalance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
publicvoidonSet(CommandContextctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}

License

MIT License — Copyright (c) 2026 mzcy_ and contributors.

Built with ❤ for the Paper ecosystem.

About

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - mzcydev/paper-core: A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle. · GitHub
Skip to content

Repository files navigation

Core Framework

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok

Table of Contents


Overview

Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:

SubsystemWhat it gives you
DI ContainerConstructor, field, and method injection with singleton/prototype scopes
Class ScannerAutomatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui
Config FrameworkType-safe YAML/JSON configs as plain Java objects
Command FrameworkAnnotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed
Inventory FrameworkFluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation
Data StoreBinary, non-human-readable persistent key-value storage per plugin
Display SystemsTitle/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities)
NPC FrameworkCitizens-free NPC system with hologram labels, skin support, and click actions
Interaction SystemsChat input, multi-step forms, context menus, NPC conversation trees
Task PipelineFluent async/sync task chains with @Task annotation scheduling
Loot TablesWeighted, pool-based loot system with conditions and @LootTableDef auto-registration
Particle SystemTyped particle effects, geometric shapes, and animated sequences
PlaceholderAPISoft-dependency integration with auto-discovered PlaceholderProvider components
Item BuildersModular, typed fluent builders for every item meta variant
Hot-ReloadConfig + listener reload without restart via @Reloadable and /core reload
Debug Overlay/core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev
UtilitiesScheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions

Architecture

CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle

Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.


Getting Started

Adding Core as a Dependency

build.gradle.kts (your plugin):

repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}

plugin.yml:

depend:
- Core

That's it. Core handles all scanning, injection, and registration automatically.

Project Structure

Recommended package layout for a plugin using Core:

dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component

Your MyPlugin.java just needs to trigger Core's scanner:

publicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}

Dependency Injection

Registering Components

@ComponentpublicclassEconomyService {
publicvoidaddBalance(UUIDplayer, doubleamount) { ... }
}

The scanner discovers @Component classes automatically. You never call new EconomyService().

Injecting Dependencies

@ComponentpublicclassShopService {
// Field injection@InjectprivateEconomyServiceeconomyService;
// Constructor injection (preferred — makes dependencies explicit)@InjectpublicShopService(EconomyServiceeconomyService, MainConfigconfig) { ... }
// Method injection (called after field injection)@InjectpublicvoidsetConfig(MainConfigconfig) { ... }
}

Scopes

AnnotationBehavior
@Singleton (default)One shared instance for the entire container lifetime
@PrototypeNew instance created on every injection point

GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.

Named Qualifiers

container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject@Named("mysql") privateDataSourceprimaryDatabase;
@Inject@Named("redis") privateDataSourcecacheDatabase;

Lifecycle Callbacks

@ComponentpublicclassConnectionPool {
@PostConstruct// called after all @Inject fields are resolvedpublicvoidinit() { openConnections(); }
@PreDestroy// called before the container destroys this instancepublicvoidcleanup() { closeConnections(); }
}

Manual Binding

Containercontainer = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> newPdfReport(newFileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyServiceservice = container.resolve(EconomyService.class);

Config Framework

Creating a Config

@Config(value = "settings", format = ConfigFormat.YAML)
publicclassMainConfigextendsAbstractConfig {
publicStringprefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
publicbooleandebug = false;
publicintmaxHomes = 5;
publicList<String> worlds = List.of("world", "world_nether");
publicDatabaseSectiondatabase = newDatabaseSection();
publicstaticclassDatabaseSectionimplementsjava.io.Serializable {
publicStringhost = "localhost";
publicintport = 3306;
publicStringname = "myplugin";
}
@OverrideprotectedvoidonLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}

The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file does not exist.

AttributeDefaultDescription
valuerequiredFilename without extension
formatYAMLYAML or JSON
directory"" (root)Sub-directory within the data folder
autoSavetrueSave on plugin disable
copyDefaultstrueCopy from JAR resources if file missing

Accessing Configs

@InjectprivateMainConfigconfig;
// Or via managerMainConfigconfig = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);

Reloading

config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();

Formats

YAML (default):

prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] 'debug: falsemaxHomes: 5

JSON:

{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}

Command Framework

Root Commands

Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:

@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
publicclassHomeCommandextendsBaseCommand {
@InjectprivateHomeServicehomeService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}

Sub-Commands

@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
publicvoidonSet(CommandContextctx) {
finalStringname = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
publicvoidonDelete(CommandContextctx) { ... }
@SubCommand(value = "list", playerOnly = true)
publicvoidonList(CommandContextctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
publicvoidonTeleport(CommandContextctx) { ... }

Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.

CommandContext API

ctx.isPlayer();
ctx.player(); // Optional<Player>ctx.playerOrThrow(); // Player — throws if not playerctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>ctx.argInt(1); // Optional<Integer>ctx.argDouble(2); // Optional<Double>ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onwardctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");

Tab Completion

@OverrideprotectedList<String> onTabComplete(@NotNullCommandContextctx) {
if (ctx.argCount() == 1) returnhomeService.listHomes(ctx.playerOrThrow());
returnsuper.onTabComplete(ctx);
}
@OverrideprotectedList<String> onSubTabComplete(@NotNullCommandContextctx,
@NotNullSubCommandHandlerhandler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
returnhomeService.listHomes(ctx.playerOrThrow());
}
returnCollections.emptyList();
}

Cooldown System

Apply @Cooldown to any @Command class or @SubCommand method:

@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
publicvoidonHeal(CommandContextctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
publicvoidonDaily(CommandContextctx) { ... }
// Custom bypass permission@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
publicvoidonOther(CommandContextctx) { ... }
PlaceholderDescription
<remaining>Human-readable time remaining
<total>Total cooldown duration

Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:

CooldownManagercooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");

Inventory Framework

Creating a GUI

@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
publicclassMainMenuGuiextendsAbstractGui {
@InjectprivateHomeServicehomeService;
@Overrideprotectedvoidbuild(@NotNullGuiBuilderbuilder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@OverrideprotectedvoidonOpen(@NotNullPlayerplayer) { ... }
@OverrideprotectedvoidonClose(@NotNullPlayerplayer) { ... }
}

Opening a GUI

CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGuigui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);

Refreshing a GUI

CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);

GuiBuilder API

builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slot

Paged GUI

Extend PagedGui for automatic pagination:

@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
publicclassHomeListGuiextendsPagedGui {
@InjectprivateHomeServicehomeService;
@OverrideprotectedList<PagedItem> getItems() {
returnhomeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides@OverrideprotectedList<Integer> getContentSlots() { ... }
@OverrideprotectedvoiddecorateBackground(@NotNullGuiBuilderbuilder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@OverrideprotectedItemStackbuildPreviousButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildNextButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildPageIndicator(@NotNullPageContextctx) { ... }
}

For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():

@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
publicclassPlayerListGuiextendsSearchablePagedGui {
@OverrideprotectedList<PagedItem> getAllItems() { ... }
@OverrideprotectedvoidbuildControls(@NotNullGuiBuilderbuilder, @NotNullPageContextctx) {
super.buildControls(builder, ctx); // prev / indicator / nextbuilder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}

Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().


Data Store

Defining a Store

@DataStore(value = "playerdata", directory = "data")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}

Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.

CRUD Operations

store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>store.remove(uuid); // returns true if removedstore.getAll(); // Map<UUID, PlayerData>store.contains(uuid);
store.size();

TTL / Expiry

store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});

Custom Key Types

@DataStore("factiondata")
publicclassFactionDataStoreextendsAbstractDataStore<String, FactionData> {
publicFactionDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullStringkey) {
returnkey.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@OverrideprotectedStringfileNameToKey(@NotNullStringfileName) { returnfileName; }
}

Event Listeners

@Component@ListenerpublicclassPlayerJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateMainConfigconfig;
@EventHandler(priority = EventPriority.NORMAL)
publicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandlerpublicvoidonQuit(PlayerQuitEventevent) {
playerService.savePlayer(event.getPlayer());
}
}

The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.


Module System

Creating a Module

publicclassEconomyModuleextendsAbstractCoreModule {
privatefinalContainercontainer;
publicEconomyModule(Containercontainer) {
super("Economy");
this.container = container;
}
@OverrideprotectedvoidonLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@OverrideprotectedvoidonEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@OverrideprotectedvoidonDisable() {
container.resolve(TransactionLog.class).flush();
}
}

Registering Modules

core.getModuleRegistry().register(newEconomyModule(core.getContainer()));

Lifecycle Order

Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)

Item Builders

All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.

ItemBuilder

ItemStacksword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStackfiller = ItemBuilder.filler();
ItemStackredFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);

SkullBuilder

SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();

LeatherArmorBuilder

LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();

BookBuilder

BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();

FireworkBuilder

FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();

Display Systems

Title & Actionbar Manager

// Titles — fluent builderTitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcutsTitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwritingactionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = backgroundactionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);

Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.

Boss Bar Manager

// Countdown bar — progress automatically decreases to 0bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updatesbossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notificationbossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");

Scoreboard Framework

Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:

scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on joinscoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one playerscoreboardManager.hide(player);
// Dynamic titlescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific linescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));

Hologram Framework

Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:

// Text + item mixed hologramhologramManager.builder("shop_sign", location)
.item(newItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologramhologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboardhologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawnhologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5shologramManager.remove("shop_sign");
hologramManager.removeAll();

NPC Framework

Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:

npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(newLocation(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram updatenpcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();

Interaction Systems

Chat Input Handler

chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
caseCOMPLETED -> homeService.createHome(player, result.getValue());
caseCANCELLED -> player.sendMessage("<gray>Cancelled.");
caseTIMED_OUT -> player.sendMessage("<red>Too slow!");
caseDISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});

Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(), integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().

Form System

Multi-step sequential input forms:

Formform = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)formManager.open(form, player).thenAccept(response -> { ... });

FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(), isCancelled(), getCancelledAtField().

Menu System

Lightweight chat-based context menus — no inventory needed. Players click text or type a number:

ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);

Output in chat:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.

MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.

Conversation System

Branching NPC dialogue trees with conditions, actions, and player choice:

ConversationTreetree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC clickconversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});

Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions. ConversationContext provides key-value data store and flag system between nodes.


Task Pipeline

Fluent async/sync task chains with automatic thread-switching mid-chain:

taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main threadctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>

Guard clauses mid-chain:

.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
returnbalance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))

Scheduled tasks via @Task — auto-discovered on boot:

@ComponentpublicclassBackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
publicvoidrunBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
publicvoidstartupCheck() { database.verifySchema(); }
}

Manual scheduling:

taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();

Loot Table System

Weighted pool-based drop system with conditions and @LootTableDef auto-registration:

@ComponentpublicclassModLootTables {
@LootTableDefpublicLootTablezombieDrops() {
returnLootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(newItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(newItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDefpublicLootTablebossDrops() {
returnLootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(newItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}

Rolling API:

// Roll for a player (auto-detects looting enchantment level)lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world locationlootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifierLootContextctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);

Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(), hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().


Particle System

// One-shot static effectsParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with genericsParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effectsParticleAnimatorhelix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimatorpulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full controlnewParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();

Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.


PlaceholderAPI Integration

Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:

softdepend:
- PlaceholderAPI
// Option A — implement PlaceholderProvider on any @Component (auto-discovered)@ComponentpublicclassEconomyPlaceholdersimplementsPlaceholderProvider {
@InjectprivateEconomyServiceeconomy;
@OverridepublicvoidregisterPlaceholders(@NotNullPlaceholderRegistryregistry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registrationCorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a stringStringmsg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one callComponentcomp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");

Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%, %core_player_name%, %core_player_uuid%, %core_player_online%.

Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value), registerOnline() (null-safe player).


Update Checker

Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.

// One-liner — async, logs to console automaticallynewUpdateChecker(this).checkAsync();
// With callback (runs on main thread)newUpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)UpdateResultresult = newUpdateChecker(this).checkSync();

Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.


Hot-Reload

Reload configs and listeners without restarting the server. Triggered via /core reload.

// Annotate any @Component method — auto-discovered@ComponentpublicclassShopService {
@InjectprivateShopConfigconfig;
@Reloadable(name = "Shop Service", order = 20)
publicvoidreload() {
config.reload();
rebuildCache();
}
}
// Register a manual stephotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmaticallyReloadResultresult = hotReloadManager.reload(sender);
switch (result.getStatus()) {
caseSUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
casePARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
caseALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}

Reload phases (in order):

  1. Unregister all managed Bukkit listeners
  2. Execute built-in steps (Configs.reloadAll(), listener re-registration)
  3. Execute registered ReloadSteps
  4. Execute @Reloadable methods sorted by order
  5. Re-register all listeners
  6. Re-inject all singleton fields (new config values propagate automatically)

Debug Overlay

/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload

Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.

Add custom entries via @Debug on any @Component:

@Component@DebugpublicclassShopDebugInfo {
@InjectprivateShopServiceshopService;
@Debug(category = "Shop", label = "Active Listings")
publicStringlistings() {
returnString.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
publicStringrevenue() {
return"<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
publicMap<String, Integer> breakdown() {
returnshopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registrationcore.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());

The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and generator included.


Utilities

SchedulerUtil

SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTasktask = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticksSchedulerUtil.minutes(1); // → 1200 ticks

ComponentUtil

ComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");

ColorUtil

ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optionalColorUtil.fromHex("#00F"); // shorthand expands to #0000FFColorUtil.fromHexSafe("#invalid"); // returns null on failureColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);

TimeUtil

TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"TimeUtil.formatSeconds(45); // → "45s"TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)TimeUtil.parse("2d12h"); // → Duration.ofHours(60)TimeUtil.parseSafe("bad_input"); // → Duration.ZEROTimeUtil.toTicks(Duration.ofSeconds(5)); // → 100TimeUtil.fromTicks(200); // → 10 seconds

SoundUtil

// Presets — zero allocation, all static finalSoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effectSoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequencesSoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);

Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE, OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT, WITHDRAW.

Preconditions

Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");

Annotation Reference

AnnotationTargetPurpose
@ComponentClassRegister as DI-managed component
@SingletonClassExplicit singleton scope (default)
@PrototypeClassNew instance per injection point
@InjectField / Constructor / MethodMark injection point
@Named("id")Field / ParameterQualify injection by name
@PostConstructMethodCalled after all fields injected
@PreDestroyMethodCalled before container destroys instance
@Config(...)ClassDeclare a config file binding
@Command(...)ClassRegister a command handler
@SubCommand(...)MethodRegister a sub-command on a BaseCommand
@Cooldown(...)Method / ClassApply cooldown to a command or sub-command
@ListenerClassAuto-register as Bukkit event listener
@DataStore(...)ClassRegister a binary data store
@InventoryGui(...)ClassRegister a GUI inventory
@Task(...)MethodSchedule a recurring or one-shot task
@Reloadable(...)MethodInvoked during hot-reload
@Debug(...)Method / ClassExpose info to /core debug
@LootTableDefMethodRegister a LootTable factory method

Exception Hierarchy

CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open

All exceptions are unchecked and carry a descriptive message including the failing component name.


Boot Sequence

onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)

Full Example Plugin

// MyPlugin.javapublicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java@Config("config")
publicclassExampleConfigextendsAbstractConfig {
publicStringwelcomeMessage = "<green>Welcome, <player>!";
publicintstartBalance = 100;
}
// PlayerDataStore.java@DataStore("players")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}
// PlayerService.java@ComponentpublicclassPlayerService {
@InjectprivatePlayerDataStorestore;
@InjectprivateExampleConfigconfig;
publicvoidonJoin(Playerplayer) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
newPlayerData(player.getName(), config.startBalance));
}
}
publicintgetBalance(UUIDuuid) {
returnstore.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
publicvoidreload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java@Component@ListenerpublicclassJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateExampleConfigconfig;
@EventHandlerpublicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
publicclassBalanceCommandextendsBaseCommand {
@InjectprivatePlayerServiceplayerService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
finalintbalance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
publicvoidonSet(CommandContextctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}

License

MIT License — Copyright (c) 2026 mzcy_ and contributors.

Built with ❤ for the Paper ecosystem.

About

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - mzcydev/paper-core: A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle. · GitHub
Skip to content

Repository files navigation

Core Framework

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok

Table of Contents


Overview

Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:

SubsystemWhat it gives you
DI ContainerConstructor, field, and method injection with singleton/prototype scopes
Class ScannerAutomatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui
Config FrameworkType-safe YAML/JSON configs as plain Java objects
Command FrameworkAnnotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed
Inventory FrameworkFluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation
Data StoreBinary, non-human-readable persistent key-value storage per plugin
Display SystemsTitle/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities)
NPC FrameworkCitizens-free NPC system with hologram labels, skin support, and click actions
Interaction SystemsChat input, multi-step forms, context menus, NPC conversation trees
Task PipelineFluent async/sync task chains with @Task annotation scheduling
Loot TablesWeighted, pool-based loot system with conditions and @LootTableDef auto-registration
Particle SystemTyped particle effects, geometric shapes, and animated sequences
PlaceholderAPISoft-dependency integration with auto-discovered PlaceholderProvider components
Item BuildersModular, typed fluent builders for every item meta variant
Hot-ReloadConfig + listener reload without restart via @Reloadable and /core reload
Debug Overlay/core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev
UtilitiesScheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions

Architecture

CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle

Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.


Getting Started

Adding Core as a Dependency

build.gradle.kts (your plugin):

repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}

plugin.yml:

depend:
- Core

That's it. Core handles all scanning, injection, and registration automatically.

Project Structure

Recommended package layout for a plugin using Core:

dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component

Your MyPlugin.java just needs to trigger Core's scanner:

publicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}

Dependency Injection

Registering Components

@ComponentpublicclassEconomyService {
publicvoidaddBalance(UUIDplayer, doubleamount) { ... }
}

The scanner discovers @Component classes automatically. You never call new EconomyService().

Injecting Dependencies

@ComponentpublicclassShopService {
// Field injection@InjectprivateEconomyServiceeconomyService;
// Constructor injection (preferred — makes dependencies explicit)@InjectpublicShopService(EconomyServiceeconomyService, MainConfigconfig) { ... }
// Method injection (called after field injection)@InjectpublicvoidsetConfig(MainConfigconfig) { ... }
}

Scopes

AnnotationBehavior
@Singleton (default)One shared instance for the entire container lifetime
@PrototypeNew instance created on every injection point

GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.

Named Qualifiers

container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject@Named("mysql") privateDataSourceprimaryDatabase;
@Inject@Named("redis") privateDataSourcecacheDatabase;

Lifecycle Callbacks

@ComponentpublicclassConnectionPool {
@PostConstruct// called after all @Inject fields are resolvedpublicvoidinit() { openConnections(); }
@PreDestroy// called before the container destroys this instancepublicvoidcleanup() { closeConnections(); }
}

Manual Binding

Containercontainer = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> newPdfReport(newFileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyServiceservice = container.resolve(EconomyService.class);

Config Framework

Creating a Config

@Config(value = "settings", format = ConfigFormat.YAML)
publicclassMainConfigextendsAbstractConfig {
publicStringprefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
publicbooleandebug = false;
publicintmaxHomes = 5;
publicList<String> worlds = List.of("world", "world_nether");
publicDatabaseSectiondatabase = newDatabaseSection();
publicstaticclassDatabaseSectionimplementsjava.io.Serializable {
publicStringhost = "localhost";
publicintport = 3306;
publicStringname = "myplugin";
}
@OverrideprotectedvoidonLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}

The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file does not exist.

AttributeDefaultDescription
valuerequiredFilename without extension
formatYAMLYAML or JSON
directory"" (root)Sub-directory within the data folder
autoSavetrueSave on plugin disable
copyDefaultstrueCopy from JAR resources if file missing

Accessing Configs

@InjectprivateMainConfigconfig;
// Or via managerMainConfigconfig = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);

Reloading

config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();

Formats

YAML (default):

prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] 'debug: falsemaxHomes: 5

JSON:

{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}

Command Framework

Root Commands

Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:

@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
publicclassHomeCommandextendsBaseCommand {
@InjectprivateHomeServicehomeService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}

Sub-Commands

@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
publicvoidonSet(CommandContextctx) {
finalStringname = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
publicvoidonDelete(CommandContextctx) { ... }
@SubCommand(value = "list", playerOnly = true)
publicvoidonList(CommandContextctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
publicvoidonTeleport(CommandContextctx) { ... }

Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.

CommandContext API

ctx.isPlayer();
ctx.player(); // Optional<Player>ctx.playerOrThrow(); // Player — throws if not playerctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>ctx.argInt(1); // Optional<Integer>ctx.argDouble(2); // Optional<Double>ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onwardctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");

Tab Completion

@OverrideprotectedList<String> onTabComplete(@NotNullCommandContextctx) {
if (ctx.argCount() == 1) returnhomeService.listHomes(ctx.playerOrThrow());
returnsuper.onTabComplete(ctx);
}
@OverrideprotectedList<String> onSubTabComplete(@NotNullCommandContextctx,
@NotNullSubCommandHandlerhandler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
returnhomeService.listHomes(ctx.playerOrThrow());
}
returnCollections.emptyList();
}

Cooldown System

Apply @Cooldown to any @Command class or @SubCommand method:

@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
publicvoidonHeal(CommandContextctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
publicvoidonDaily(CommandContextctx) { ... }
// Custom bypass permission@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
publicvoidonOther(CommandContextctx) { ... }
PlaceholderDescription
<remaining>Human-readable time remaining
<total>Total cooldown duration

Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:

CooldownManagercooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");

Inventory Framework

Creating a GUI

@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
publicclassMainMenuGuiextendsAbstractGui {
@InjectprivateHomeServicehomeService;
@Overrideprotectedvoidbuild(@NotNullGuiBuilderbuilder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@OverrideprotectedvoidonOpen(@NotNullPlayerplayer) { ... }
@OverrideprotectedvoidonClose(@NotNullPlayerplayer) { ... }
}

Opening a GUI

CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGuigui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);

Refreshing a GUI

CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);

GuiBuilder API

builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slot

Paged GUI

Extend PagedGui for automatic pagination:

@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
publicclassHomeListGuiextendsPagedGui {
@InjectprivateHomeServicehomeService;
@OverrideprotectedList<PagedItem> getItems() {
returnhomeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides@OverrideprotectedList<Integer> getContentSlots() { ... }
@OverrideprotectedvoiddecorateBackground(@NotNullGuiBuilderbuilder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@OverrideprotectedItemStackbuildPreviousButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildNextButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildPageIndicator(@NotNullPageContextctx) { ... }
}

For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():

@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
publicclassPlayerListGuiextendsSearchablePagedGui {
@OverrideprotectedList<PagedItem> getAllItems() { ... }
@OverrideprotectedvoidbuildControls(@NotNullGuiBuilderbuilder, @NotNullPageContextctx) {
super.buildControls(builder, ctx); // prev / indicator / nextbuilder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}

Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().


Data Store

Defining a Store

@DataStore(value = "playerdata", directory = "data")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}

Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.

CRUD Operations

store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>store.remove(uuid); // returns true if removedstore.getAll(); // Map<UUID, PlayerData>store.contains(uuid);
store.size();

TTL / Expiry

store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});

Custom Key Types

@DataStore("factiondata")
publicclassFactionDataStoreextendsAbstractDataStore<String, FactionData> {
publicFactionDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullStringkey) {
returnkey.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@OverrideprotectedStringfileNameToKey(@NotNullStringfileName) { returnfileName; }
}

Event Listeners

@Component@ListenerpublicclassPlayerJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateMainConfigconfig;
@EventHandler(priority = EventPriority.NORMAL)
publicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandlerpublicvoidonQuit(PlayerQuitEventevent) {
playerService.savePlayer(event.getPlayer());
}
}

The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.


Module System

Creating a Module

publicclassEconomyModuleextendsAbstractCoreModule {
privatefinalContainercontainer;
publicEconomyModule(Containercontainer) {
super("Economy");
this.container = container;
}
@OverrideprotectedvoidonLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@OverrideprotectedvoidonEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@OverrideprotectedvoidonDisable() {
container.resolve(TransactionLog.class).flush();
}
}

Registering Modules

core.getModuleRegistry().register(newEconomyModule(core.getContainer()));

Lifecycle Order

Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)

Item Builders

All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.

ItemBuilder

ItemStacksword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStackfiller = ItemBuilder.filler();
ItemStackredFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);

SkullBuilder

SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();

LeatherArmorBuilder

LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();

BookBuilder

BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();

FireworkBuilder

FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();

Display Systems

Title & Actionbar Manager

// Titles — fluent builderTitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcutsTitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwritingactionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = backgroundactionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);

Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.

Boss Bar Manager

// Countdown bar — progress automatically decreases to 0bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updatesbossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notificationbossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");

Scoreboard Framework

Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:

scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on joinscoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one playerscoreboardManager.hide(player);
// Dynamic titlescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific linescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));

Hologram Framework

Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:

// Text + item mixed hologramhologramManager.builder("shop_sign", location)
.item(newItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologramhologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboardhologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawnhologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5shologramManager.remove("shop_sign");
hologramManager.removeAll();

NPC Framework

Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:

npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(newLocation(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram updatenpcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();

Interaction Systems

Chat Input Handler

chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
caseCOMPLETED -> homeService.createHome(player, result.getValue());
caseCANCELLED -> player.sendMessage("<gray>Cancelled.");
caseTIMED_OUT -> player.sendMessage("<red>Too slow!");
caseDISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});

Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(), integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().

Form System

Multi-step sequential input forms:

Formform = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)formManager.open(form, player).thenAccept(response -> { ... });

FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(), isCancelled(), getCancelledAtField().

Menu System

Lightweight chat-based context menus — no inventory needed. Players click text or type a number:

ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);

Output in chat:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.

MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.

Conversation System

Branching NPC dialogue trees with conditions, actions, and player choice:

ConversationTreetree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC clickconversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});

Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions. ConversationContext provides key-value data store and flag system between nodes.


Task Pipeline

Fluent async/sync task chains with automatic thread-switching mid-chain:

taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main threadctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>

Guard clauses mid-chain:

.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
returnbalance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))

Scheduled tasks via @Task — auto-discovered on boot:

@ComponentpublicclassBackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
publicvoidrunBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
publicvoidstartupCheck() { database.verifySchema(); }
}

Manual scheduling:

taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();

Loot Table System

Weighted pool-based drop system with conditions and @LootTableDef auto-registration:

@ComponentpublicclassModLootTables {
@LootTableDefpublicLootTablezombieDrops() {
returnLootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(newItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(newItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDefpublicLootTablebossDrops() {
returnLootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(newItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}

Rolling API:

// Roll for a player (auto-detects looting enchantment level)lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world locationlootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifierLootContextctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);

Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(), hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().


Particle System

// One-shot static effectsParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with genericsParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effectsParticleAnimatorhelix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimatorpulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full controlnewParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();

Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.


PlaceholderAPI Integration

Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:

softdepend:
- PlaceholderAPI
// Option A — implement PlaceholderProvider on any @Component (auto-discovered)@ComponentpublicclassEconomyPlaceholdersimplementsPlaceholderProvider {
@InjectprivateEconomyServiceeconomy;
@OverridepublicvoidregisterPlaceholders(@NotNullPlaceholderRegistryregistry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registrationCorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a stringStringmsg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one callComponentcomp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");

Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%, %core_player_name%, %core_player_uuid%, %core_player_online%.

Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value), registerOnline() (null-safe player).


Update Checker

Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.

// One-liner — async, logs to console automaticallynewUpdateChecker(this).checkAsync();
// With callback (runs on main thread)newUpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)UpdateResultresult = newUpdateChecker(this).checkSync();

Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.


Hot-Reload

Reload configs and listeners without restarting the server. Triggered via /core reload.

// Annotate any @Component method — auto-discovered@ComponentpublicclassShopService {
@InjectprivateShopConfigconfig;
@Reloadable(name = "Shop Service", order = 20)
publicvoidreload() {
config.reload();
rebuildCache();
}
}
// Register a manual stephotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmaticallyReloadResultresult = hotReloadManager.reload(sender);
switch (result.getStatus()) {
caseSUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
casePARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
caseALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}

Reload phases (in order):

  1. Unregister all managed Bukkit listeners
  2. Execute built-in steps (Configs.reloadAll(), listener re-registration)
  3. Execute registered ReloadSteps
  4. Execute @Reloadable methods sorted by order
  5. Re-register all listeners
  6. Re-inject all singleton fields (new config values propagate automatically)

Debug Overlay

/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload

Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.

Add custom entries via @Debug on any @Component:

@Component@DebugpublicclassShopDebugInfo {
@InjectprivateShopServiceshopService;
@Debug(category = "Shop", label = "Active Listings")
publicStringlistings() {
returnString.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
publicStringrevenue() {
return"<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
publicMap<String, Integer> breakdown() {
returnshopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registrationcore.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());

The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and generator included.


Utilities

SchedulerUtil

SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTasktask = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticksSchedulerUtil.minutes(1); // → 1200 ticks

ComponentUtil

ComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");

ColorUtil

ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optionalColorUtil.fromHex("#00F"); // shorthand expands to #0000FFColorUtil.fromHexSafe("#invalid"); // returns null on failureColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);

TimeUtil

TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"TimeUtil.formatSeconds(45); // → "45s"TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)TimeUtil.parse("2d12h"); // → Duration.ofHours(60)TimeUtil.parseSafe("bad_input"); // → Duration.ZEROTimeUtil.toTicks(Duration.ofSeconds(5)); // → 100TimeUtil.fromTicks(200); // → 10 seconds

SoundUtil

// Presets — zero allocation, all static finalSoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effectSoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequencesSoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);

Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE, OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT, WITHDRAW.

Preconditions

Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");

Annotation Reference

AnnotationTargetPurpose
@ComponentClassRegister as DI-managed component
@SingletonClassExplicit singleton scope (default)
@PrototypeClassNew instance per injection point
@InjectField / Constructor / MethodMark injection point
@Named("id")Field / ParameterQualify injection by name
@PostConstructMethodCalled after all fields injected
@PreDestroyMethodCalled before container destroys instance
@Config(...)ClassDeclare a config file binding
@Command(...)ClassRegister a command handler
@SubCommand(...)MethodRegister a sub-command on a BaseCommand
@Cooldown(...)Method / ClassApply cooldown to a command or sub-command
@ListenerClassAuto-register as Bukkit event listener
@DataStore(...)ClassRegister a binary data store
@InventoryGui(...)ClassRegister a GUI inventory
@Task(...)MethodSchedule a recurring or one-shot task
@Reloadable(...)MethodInvoked during hot-reload
@Debug(...)Method / ClassExpose info to /core debug
@LootTableDefMethodRegister a LootTable factory method

Exception Hierarchy

CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open

All exceptions are unchecked and carry a descriptive message including the failing component name.


Boot Sequence

onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)

Full Example Plugin

// MyPlugin.javapublicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java@Config("config")
publicclassExampleConfigextendsAbstractConfig {
publicStringwelcomeMessage = "<green>Welcome, <player>!";
publicintstartBalance = 100;
}
// PlayerDataStore.java@DataStore("players")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}
// PlayerService.java@ComponentpublicclassPlayerService {
@InjectprivatePlayerDataStorestore;
@InjectprivateExampleConfigconfig;
publicvoidonJoin(Playerplayer) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
newPlayerData(player.getName(), config.startBalance));
}
}
publicintgetBalance(UUIDuuid) {
returnstore.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
publicvoidreload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java@Component@ListenerpublicclassJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateExampleConfigconfig;
@EventHandlerpublicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
publicclassBalanceCommandextendsBaseCommand {
@InjectprivatePlayerServiceplayerService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
finalintbalance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
publicvoidonSet(CommandContextctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}

License

MIT License — Copyright (c) 2026 mzcy_ and contributors.

Built with ❤ for the Paper ecosystem.

About

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - mzcydev/paper-core: A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle. · GitHub
Skip to content

Repository files navigation

Core Framework

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok

Table of Contents


Overview

Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:

SubsystemWhat it gives you
DI ContainerConstructor, field, and method injection with singleton/prototype scopes
Class ScannerAutomatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui
Config FrameworkType-safe YAML/JSON configs as plain Java objects
Command FrameworkAnnotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed
Inventory FrameworkFluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation
Data StoreBinary, non-human-readable persistent key-value storage per plugin
Display SystemsTitle/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities)
NPC FrameworkCitizens-free NPC system with hologram labels, skin support, and click actions
Interaction SystemsChat input, multi-step forms, context menus, NPC conversation trees
Task PipelineFluent async/sync task chains with @Task annotation scheduling
Loot TablesWeighted, pool-based loot system with conditions and @LootTableDef auto-registration
Particle SystemTyped particle effects, geometric shapes, and animated sequences
PlaceholderAPISoft-dependency integration with auto-discovered PlaceholderProvider components
Item BuildersModular, typed fluent builders for every item meta variant
Hot-ReloadConfig + listener reload without restart via @Reloadable and /core reload
Debug Overlay/core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev
UtilitiesScheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions

Architecture

CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle

Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.


Getting Started

Adding Core as a Dependency

build.gradle.kts (your plugin):

repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}

plugin.yml:

depend:
- Core

That's it. Core handles all scanning, injection, and registration automatically.

Project Structure

Recommended package layout for a plugin using Core:

dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component

Your MyPlugin.java just needs to trigger Core's scanner:

publicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}

Dependency Injection

Registering Components

@ComponentpublicclassEconomyService {
publicvoidaddBalance(UUIDplayer, doubleamount) { ... }
}

The scanner discovers @Component classes automatically. You never call new EconomyService().

Injecting Dependencies

@ComponentpublicclassShopService {
// Field injection@InjectprivateEconomyServiceeconomyService;
// Constructor injection (preferred — makes dependencies explicit)@InjectpublicShopService(EconomyServiceeconomyService, MainConfigconfig) { ... }
// Method injection (called after field injection)@InjectpublicvoidsetConfig(MainConfigconfig) { ... }
}

Scopes

AnnotationBehavior
@Singleton (default)One shared instance for the entire container lifetime
@PrototypeNew instance created on every injection point

GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.

Named Qualifiers

container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject@Named("mysql") privateDataSourceprimaryDatabase;
@Inject@Named("redis") privateDataSourcecacheDatabase;

Lifecycle Callbacks

@ComponentpublicclassConnectionPool {
@PostConstruct// called after all @Inject fields are resolvedpublicvoidinit() { openConnections(); }
@PreDestroy// called before the container destroys this instancepublicvoidcleanup() { closeConnections(); }
}

Manual Binding

Containercontainer = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> newPdfReport(newFileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyServiceservice = container.resolve(EconomyService.class);

Config Framework

Creating a Config

@Config(value = "settings", format = ConfigFormat.YAML)
publicclassMainConfigextendsAbstractConfig {
publicStringprefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
publicbooleandebug = false;
publicintmaxHomes = 5;
publicList<String> worlds = List.of("world", "world_nether");
publicDatabaseSectiondatabase = newDatabaseSection();
publicstaticclassDatabaseSectionimplementsjava.io.Serializable {
publicStringhost = "localhost";
publicintport = 3306;
publicStringname = "myplugin";
}
@OverrideprotectedvoidonLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}

The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file does not exist.

AttributeDefaultDescription
valuerequiredFilename without extension
formatYAMLYAML or JSON
directory"" (root)Sub-directory within the data folder
autoSavetrueSave on plugin disable
copyDefaultstrueCopy from JAR resources if file missing

Accessing Configs

@InjectprivateMainConfigconfig;
// Or via managerMainConfigconfig = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);

Reloading

config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();

Formats

YAML (default):

prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] 'debug: falsemaxHomes: 5

JSON:

{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}

Command Framework

Root Commands

Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:

@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
publicclassHomeCommandextendsBaseCommand {
@InjectprivateHomeServicehomeService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}

Sub-Commands

@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
publicvoidonSet(CommandContextctx) {
finalStringname = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
publicvoidonDelete(CommandContextctx) { ... }
@SubCommand(value = "list", playerOnly = true)
publicvoidonList(CommandContextctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
publicvoidonTeleport(CommandContextctx) { ... }

Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.

CommandContext API

ctx.isPlayer();
ctx.player(); // Optional<Player>ctx.playerOrThrow(); // Player — throws if not playerctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>ctx.argInt(1); // Optional<Integer>ctx.argDouble(2); // Optional<Double>ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onwardctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");

Tab Completion

@OverrideprotectedList<String> onTabComplete(@NotNullCommandContextctx) {
if (ctx.argCount() == 1) returnhomeService.listHomes(ctx.playerOrThrow());
returnsuper.onTabComplete(ctx);
}
@OverrideprotectedList<String> onSubTabComplete(@NotNullCommandContextctx,
@NotNullSubCommandHandlerhandler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
returnhomeService.listHomes(ctx.playerOrThrow());
}
returnCollections.emptyList();
}

Cooldown System

Apply @Cooldown to any @Command class or @SubCommand method:

@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
publicvoidonHeal(CommandContextctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
publicvoidonDaily(CommandContextctx) { ... }
// Custom bypass permission@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
publicvoidonOther(CommandContextctx) { ... }
PlaceholderDescription
<remaining>Human-readable time remaining
<total>Total cooldown duration

Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:

CooldownManagercooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");

Inventory Framework

Creating a GUI

@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
publicclassMainMenuGuiextendsAbstractGui {
@InjectprivateHomeServicehomeService;
@Overrideprotectedvoidbuild(@NotNullGuiBuilderbuilder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@OverrideprotectedvoidonOpen(@NotNullPlayerplayer) { ... }
@OverrideprotectedvoidonClose(@NotNullPlayerplayer) { ... }
}

Opening a GUI

CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGuigui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);

Refreshing a GUI

CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);

GuiBuilder API

builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slot

Paged GUI

Extend PagedGui for automatic pagination:

@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
publicclassHomeListGuiextendsPagedGui {
@InjectprivateHomeServicehomeService;
@OverrideprotectedList<PagedItem> getItems() {
returnhomeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides@OverrideprotectedList<Integer> getContentSlots() { ... }
@OverrideprotectedvoiddecorateBackground(@NotNullGuiBuilderbuilder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@OverrideprotectedItemStackbuildPreviousButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildNextButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildPageIndicator(@NotNullPageContextctx) { ... }
}

For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():

@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
publicclassPlayerListGuiextendsSearchablePagedGui {
@OverrideprotectedList<PagedItem> getAllItems() { ... }
@OverrideprotectedvoidbuildControls(@NotNullGuiBuilderbuilder, @NotNullPageContextctx) {
super.buildControls(builder, ctx); // prev / indicator / nextbuilder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}

Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().


Data Store

Defining a Store

@DataStore(value = "playerdata", directory = "data")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}

Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.

CRUD Operations

store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>store.remove(uuid); // returns true if removedstore.getAll(); // Map<UUID, PlayerData>store.contains(uuid);
store.size();

TTL / Expiry

store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});

Custom Key Types

@DataStore("factiondata")
publicclassFactionDataStoreextendsAbstractDataStore<String, FactionData> {
publicFactionDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullStringkey) {
returnkey.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@OverrideprotectedStringfileNameToKey(@NotNullStringfileName) { returnfileName; }
}

Event Listeners

@Component@ListenerpublicclassPlayerJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateMainConfigconfig;
@EventHandler(priority = EventPriority.NORMAL)
publicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandlerpublicvoidonQuit(PlayerQuitEventevent) {
playerService.savePlayer(event.getPlayer());
}
}

The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.


Module System

Creating a Module

publicclassEconomyModuleextendsAbstractCoreModule {
privatefinalContainercontainer;
publicEconomyModule(Containercontainer) {
super("Economy");
this.container = container;
}
@OverrideprotectedvoidonLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@OverrideprotectedvoidonEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@OverrideprotectedvoidonDisable() {
container.resolve(TransactionLog.class).flush();
}
}

Registering Modules

core.getModuleRegistry().register(newEconomyModule(core.getContainer()));

Lifecycle Order

Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)

Item Builders

All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.

ItemBuilder

ItemStacksword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStackfiller = ItemBuilder.filler();
ItemStackredFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);

SkullBuilder

SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();

LeatherArmorBuilder

LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();

BookBuilder

BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();

FireworkBuilder

FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();

Display Systems

Title & Actionbar Manager

// Titles — fluent builderTitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcutsTitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwritingactionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = backgroundactionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);

Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.

Boss Bar Manager

// Countdown bar — progress automatically decreases to 0bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updatesbossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notificationbossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");

Scoreboard Framework

Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:

scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on joinscoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one playerscoreboardManager.hide(player);
// Dynamic titlescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific linescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));

Hologram Framework

Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:

// Text + item mixed hologramhologramManager.builder("shop_sign", location)
.item(newItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologramhologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboardhologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawnhologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5shologramManager.remove("shop_sign");
hologramManager.removeAll();

NPC Framework

Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:

npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(newLocation(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram updatenpcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();

Interaction Systems

Chat Input Handler

chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
caseCOMPLETED -> homeService.createHome(player, result.getValue());
caseCANCELLED -> player.sendMessage("<gray>Cancelled.");
caseTIMED_OUT -> player.sendMessage("<red>Too slow!");
caseDISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});

Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(), integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().

Form System

Multi-step sequential input forms:

Formform = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)formManager.open(form, player).thenAccept(response -> { ... });

FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(), isCancelled(), getCancelledAtField().

Menu System

Lightweight chat-based context menus — no inventory needed. Players click text or type a number:

ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);

Output in chat:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.

MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.

Conversation System

Branching NPC dialogue trees with conditions, actions, and player choice:

ConversationTreetree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC clickconversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});

Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions. ConversationContext provides key-value data store and flag system between nodes.


Task Pipeline

Fluent async/sync task chains with automatic thread-switching mid-chain:

taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main threadctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>

Guard clauses mid-chain:

.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
returnbalance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))

Scheduled tasks via @Task — auto-discovered on boot:

@ComponentpublicclassBackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
publicvoidrunBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
publicvoidstartupCheck() { database.verifySchema(); }
}

Manual scheduling:

taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();

Loot Table System

Weighted pool-based drop system with conditions and @LootTableDef auto-registration:

@ComponentpublicclassModLootTables {
@LootTableDefpublicLootTablezombieDrops() {
returnLootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(newItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(newItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDefpublicLootTablebossDrops() {
returnLootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(newItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}

Rolling API:

// Roll for a player (auto-detects looting enchantment level)lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world locationlootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifierLootContextctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);

Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(), hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().


Particle System

// One-shot static effectsParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with genericsParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effectsParticleAnimatorhelix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimatorpulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full controlnewParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();

Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.


PlaceholderAPI Integration

Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:

softdepend:
- PlaceholderAPI
// Option A — implement PlaceholderProvider on any @Component (auto-discovered)@ComponentpublicclassEconomyPlaceholdersimplementsPlaceholderProvider {
@InjectprivateEconomyServiceeconomy;
@OverridepublicvoidregisterPlaceholders(@NotNullPlaceholderRegistryregistry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registrationCorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a stringStringmsg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one callComponentcomp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");

Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%, %core_player_name%, %core_player_uuid%, %core_player_online%.

Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value), registerOnline() (null-safe player).


Update Checker

Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.

// One-liner — async, logs to console automaticallynewUpdateChecker(this).checkAsync();
// With callback (runs on main thread)newUpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)UpdateResultresult = newUpdateChecker(this).checkSync();

Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.


Hot-Reload

Reload configs and listeners without restarting the server. Triggered via /core reload.

// Annotate any @Component method — auto-discovered@ComponentpublicclassShopService {
@InjectprivateShopConfigconfig;
@Reloadable(name = "Shop Service", order = 20)
publicvoidreload() {
config.reload();
rebuildCache();
}
}
// Register a manual stephotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmaticallyReloadResultresult = hotReloadManager.reload(sender);
switch (result.getStatus()) {
caseSUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
casePARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
caseALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}

Reload phases (in order):

  1. Unregister all managed Bukkit listeners
  2. Execute built-in steps (Configs.reloadAll(), listener re-registration)
  3. Execute registered ReloadSteps
  4. Execute @Reloadable methods sorted by order
  5. Re-register all listeners
  6. Re-inject all singleton fields (new config values propagate automatically)

Debug Overlay

/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload

Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.

Add custom entries via @Debug on any @Component:

@Component@DebugpublicclassShopDebugInfo {
@InjectprivateShopServiceshopService;
@Debug(category = "Shop", label = "Active Listings")
publicStringlistings() {
returnString.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
publicStringrevenue() {
return"<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
publicMap<String, Integer> breakdown() {
returnshopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registrationcore.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());

The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and generator included.


Utilities

SchedulerUtil

SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTasktask = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticksSchedulerUtil.minutes(1); // → 1200 ticks

ComponentUtil

ComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");

ColorUtil

ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optionalColorUtil.fromHex("#00F"); // shorthand expands to #0000FFColorUtil.fromHexSafe("#invalid"); // returns null on failureColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);

TimeUtil

TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"TimeUtil.formatSeconds(45); // → "45s"TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)TimeUtil.parse("2d12h"); // → Duration.ofHours(60)TimeUtil.parseSafe("bad_input"); // → Duration.ZEROTimeUtil.toTicks(Duration.ofSeconds(5)); // → 100TimeUtil.fromTicks(200); // → 10 seconds

SoundUtil

// Presets — zero allocation, all static finalSoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effectSoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequencesSoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);

Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE, OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT, WITHDRAW.

Preconditions

Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");

Annotation Reference

AnnotationTargetPurpose
@ComponentClassRegister as DI-managed component
@SingletonClassExplicit singleton scope (default)
@PrototypeClassNew instance per injection point
@InjectField / Constructor / MethodMark injection point
@Named("id")Field / ParameterQualify injection by name
@PostConstructMethodCalled after all fields injected
@PreDestroyMethodCalled before container destroys instance
@Config(...)ClassDeclare a config file binding
@Command(...)ClassRegister a command handler
@SubCommand(...)MethodRegister a sub-command on a BaseCommand
@Cooldown(...)Method / ClassApply cooldown to a command or sub-command
@ListenerClassAuto-register as Bukkit event listener
@DataStore(...)ClassRegister a binary data store
@InventoryGui(...)ClassRegister a GUI inventory
@Task(...)MethodSchedule a recurring or one-shot task
@Reloadable(...)MethodInvoked during hot-reload
@Debug(...)Method / ClassExpose info to /core debug
@LootTableDefMethodRegister a LootTable factory method

Exception Hierarchy

CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open

All exceptions are unchecked and carry a descriptive message including the failing component name.


Boot Sequence

onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)

Full Example Plugin

// MyPlugin.javapublicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java@Config("config")
publicclassExampleConfigextendsAbstractConfig {
publicStringwelcomeMessage = "<green>Welcome, <player>!";
publicintstartBalance = 100;
}
// PlayerDataStore.java@DataStore("players")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}
// PlayerService.java@ComponentpublicclassPlayerService {
@InjectprivatePlayerDataStorestore;
@InjectprivateExampleConfigconfig;
publicvoidonJoin(Playerplayer) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
newPlayerData(player.getName(), config.startBalance));
}
}
publicintgetBalance(UUIDuuid) {
returnstore.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
publicvoidreload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java@Component@ListenerpublicclassJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateExampleConfigconfig;
@EventHandlerpublicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
publicclassBalanceCommandextendsBaseCommand {
@InjectprivatePlayerServiceplayerService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
finalintbalance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
publicvoidonSet(CommandContextctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}

License

MIT License — Copyright (c) 2026 mzcy_ and contributors.

Built with ❤ for the Paper ecosystem.

About

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - mzcydev/paper-core: A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle. · GitHub
Skip to content

Repository files navigation

Core Framework

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

dev.mzcy.core · Paper 1.21.x · Java 21 · Gradle KTS · Lombok

Table of Contents


Overview

Core is a framework plugin — it does not add gameplay. It provides the infrastructure that your own plugins build on top of:

SubsystemWhat it gives you
DI ContainerConstructor, field, and method injection with singleton/prototype scopes
Class ScannerAutomatic discovery of @Component, @Command, @Config, @Listener, @DataStore, @InventoryGui
Config FrameworkType-safe YAML/JSON configs as plain Java objects
Command FrameworkAnnotation-based commands with sub-command routing, cooldowns, no plugin.yml declarations needed
Inventory FrameworkFluent GUI builder with automatic click routing, paged GUIs, and per-player state isolation
Data StoreBinary, non-human-readable persistent key-value storage per plugin
Display SystemsTitle/Actionbar manager, BossBar manager, Scoreboard sidebar, Hologram framework (Display entities)
NPC FrameworkCitizens-free NPC system with hologram labels, skin support, and click actions
Interaction SystemsChat input, multi-step forms, context menus, NPC conversation trees
Task PipelineFluent async/sync task chains with @Task annotation scheduling
Loot TablesWeighted, pool-based loot system with conditions and @LootTableDef auto-registration
Particle SystemTyped particle effects, geometric shapes, and animated sequences
PlaceholderAPISoft-dependency integration with auto-discovered PlaceholderProvider components
Item BuildersModular, typed fluent builders for every item meta variant
Hot-ReloadConfig + listener reload without restart via @Reloadable and /core reload
Debug Overlay/core debug with JVM, server, DI, and custom @Debug entries — pasteable to pastes.dev
UtilitiesScheduler, ComponentUtil, ColorUtil, TimeUtil, SoundUtil, Preconditions

Architecture

CorePlugin (Bootstrap)
│
├── Container (DI)
│ └── Injector
│
├── ComponentRegistry
│ ├── ClassScanner ← scans JAR entries
│ ├── ScanResult ← categorized class sets
│ └── AnnotationProcessor ← wires into Container
│
├── ConfigManager ← loads/saves AbstractConfig subclasses
├── DataStoreManager ← initializes AbstractDataStore subclasses
├── CommandManager ← registers BaseCommand subclasses
│ └── CooldownManager ← @Cooldown annotation enforcement
├── InventoryManager ← tracks AbstractGui instances
│ └── GuiListener ← routes Bukkit click/close events
├── ScoreboardManager ← named FastSidebar registry
├── HologramManager ← Display entity holograms
├── NpcManager ← Citizens-free NPC system
├── BossBarManager ← per-player boss bars
├── ActionbarManager ← priority-queue action bars
├── ChatInputManager ← chat input sessions
├── FormManager ← multi-step form sessions
├── MenuManager ← context menu sessions
├── ConversationManager ← NPC conversation trees
├── TaskManager ← @Task scheduling + TaskChain factory
├── LootManager ← @LootTableDef registry + rolling
├── PlaceholderManager ← PAPI soft-dependency
├── HotReloadManager ← @Reloadable + /core reload
├── DebugOverlay ← /core debug + pastes.dev upload
│ └── DebugRegistry ← @Debug entry discovery
└── ModuleRegistry ← load → enable → disable lifecycle

Every subsystem is registered as a singleton in the DI container, meaning you can inject any manager directly into your components.


Getting Started

Adding Core as a Dependency

build.gradle.kts (your plugin):

repositories {
maven("https://repo.mzcy.dev/releases") // or local
}
dependencies {
compileOnly("dev.mzcy:core:1.0.0-SNAPSHOT")
}

plugin.yml:

depend:
- Core

That's it. Core handles all scanning, injection, and registration automatically.

Project Structure

Recommended package layout for a plugin using Core:

dev.mzcy.myplugin/
├── MyPlugin.java
├── command/
│ └── SpawnCommand.java ← @Command + extends BaseCommand
├── config/
│ └── MainConfig.java ← @Config + extends AbstractConfig
├── data/
│ └── PlayerDataStore.java ← @DataStore + extends AbstractDataStore
├── gui/
│ └── MainMenuGui.java ← @InventoryGui + extends AbstractGui
├── listener/
│ └── JoinListener.java ← @Component + @Listener
├── loot/
│ └── ModLootTables.java ← @Component with @LootTableDef methods
└── service/
└── PlayerService.java ← @Component

Your MyPlugin.java just needs to trigger Core's scanner:

publicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.myplugin");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}

Dependency Injection

Registering Components

@ComponentpublicclassEconomyService {
publicvoidaddBalance(UUIDplayer, doubleamount) { ... }
}

The scanner discovers @Component classes automatically. You never call new EconomyService().

Injecting Dependencies

@ComponentpublicclassShopService {
// Field injection@InjectprivateEconomyServiceeconomyService;
// Constructor injection (preferred — makes dependencies explicit)@InjectpublicShopService(EconomyServiceeconomyService, MainConfigconfig) { ... }
// Method injection (called after field injection)@InjectpublicvoidsetConfig(MainConfigconfig) { ... }
}

Scopes

AnnotationBehavior
@Singleton (default)One shared instance for the entire container lifetime
@PrototypeNew instance created on every injection point

GUIs are automatically @Prototype — each open() call gets a fresh instance with isolated per-player state.

Named Qualifiers

container.bind(DataSource.class, MySQLDataSource.class, "mysql", Scope.SINGLETON);
container.bind(DataSource.class, RedisDataSource.class, "redis", Scope.SINGLETON);
@Inject@Named("mysql") privateDataSourceprimaryDatabase;
@Inject@Named("redis") privateDataSourcecacheDatabase;

Lifecycle Callbacks

@ComponentpublicclassConnectionPool {
@PostConstruct// called after all @Inject fields are resolvedpublicvoidinit() { openConnections(); }
@PreDestroy// called before the container destroys this instancepublicvoidcleanup() { closeConnections(); }
}

Manual Binding

Containercontainer = CorePlugin.getInstance().getContainer();
container.bind(PaymentGateway.class, StripeGateway.class);
container.bindInstance(MyLibrary.class, MyLibrary.create());
container.bindFactory(Report.class, PdfReport.class,
() -> newPdfReport(newFileOutputStream("out.pdf")), Scope.PROTOTYPE);
EconomyServiceservice = container.resolve(EconomyService.class);

Config Framework

Creating a Config

@Config(value = "settings", format = ConfigFormat.YAML)
publicclassMainConfigextendsAbstractConfig {
publicStringprefix = "<dark_gray>[<aqua>MyPlugin<dark_gray>] ";
publicbooleandebug = false;
publicintmaxHomes = 5;
publicList<String> worlds = List.of("world", "world_nether");
publicDatabaseSectiondatabase = newDatabaseSection();
publicstaticclassDatabaseSectionimplementsjava.io.Serializable {
publicStringhost = "localhost";
publicintport = 3306;
publicStringname = "myplugin";
}
@OverrideprotectedvoidonLoad() {
if (maxHomes < 1) maxHomes = 1;
}
}

The file is created at plugins/MyPlugin/settings.yml on first load. Default values serve as fallback when the file does not exist.

AttributeDefaultDescription
valuerequiredFilename without extension
formatYAMLYAML or JSON
directory"" (root)Sub-directory within the data folder
autoSavetrueSave on plugin disable
copyDefaultstrueCopy from JAR resources if file missing

Accessing Configs

@InjectprivateMainConfigconfig;
// Or via managerMainConfigconfig = CorePlugin.getInstance().getConfigManager().get(MainConfig.class);

Reloading

config.reload();
CorePlugin.getInstance().getConfigManager().reloadAll();

Formats

YAML (default):

prefix: '<dark_gray>[<aqua>MyPlugin<dark_gray>] 'debug: falsemaxHomes: 5

JSON:

{
"prefix": "<dark_gray>[<aqua>MyPlugin<dark_gray>] ",
"debug": false,
"maxHomes": 5
}

Command Framework

Root Commands

Extend BaseCommand and annotate with @Command. No plugin.yml declaration needed:

@Command(
name = "home",
description = "Manage your homes",
usage = "/home <set|delete|list|tp>",
permission = "myplugin.home",
aliases = {"homes", "h"},
playerOnly = true
)
publicclassHomeCommandextendsBaseCommand {
@InjectprivateHomeServicehomeService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
ctx.send("<yellow>Usage: /home <set|delete|list|tp>");
homeService.listHomes(ctx.playerOrThrow())
.forEach(name -> ctx.send("<gray> - " + name));
}
}

Sub-Commands

@SubCommand(value = "set", permission = "myplugin.home.set",
usage = "/home set <n>", minArgs = 1, playerOnly = true)
publicvoidonSet(CommandContextctx) {
finalStringname = ctx.arg(0).orElse("home");
homeService.setHome(ctx.playerOrThrow(), name);
ctx.sendSuccess("Home <white>" + name + "<green> set!");
}
@SubCommand(value = "delete", minArgs = 1, playerOnly = true)
publicvoidonDelete(CommandContextctx) { ... }
@SubCommand(value = "list", playerOnly = true)
publicvoidonList(CommandContextctx) { ... }
@SubCommand(value = "tp", usage = "/home tp <n>", minArgs = 1, playerOnly = true)
publicvoidonTeleport(CommandContextctx) { ... }

Routing happens automatically — /home set beach calls onSet, /home list calls onList, etc.

CommandContext API

ctx.isPlayer();
ctx.player(); // Optional<Player>ctx.playerOrThrow(); // Player — throws if not playerctx.hasPermission("node");
ctx.argCount();
ctx.arg(0); // Optional<String>ctx.argInt(1); // Optional<Integer>ctx.argDouble(2); // Optional<Double>ctx.joinArgs(1); // "arg1 arg2 arg3" from index 1 onwardctx.send("<green>Done!");
ctx.sendError("Something went wrong.");
ctx.sendSuccess("Action completed.");
ctx.sendPlain("No formatting here.");

Tab Completion

@OverrideprotectedList<String> onTabComplete(@NotNullCommandContextctx) {
if (ctx.argCount() == 1) returnhomeService.listHomes(ctx.playerOrThrow());
returnsuper.onTabComplete(ctx);
}
@OverrideprotectedList<String> onSubTabComplete(@NotNullCommandContextctx,
@NotNullSubCommandHandlerhandler) {
if (handler.token().equals("tp") || handler.token().equals("delete")) {
returnhomeService.listHomes(ctx.playerOrThrow());
}
returnCollections.emptyList();
}

Cooldown System

Apply @Cooldown to any @Command class or @SubCommand method:

@SubCommand("heal")
@Cooldown(
value = 30,
unit = TimeUnit.SECONDS,
message = "<red>Heal again in <bold><remaining></bold>."
)
publicvoidonHeal(CommandContextctx) {
ctx.playerOrThrow().setHealth(20);
ctx.sendSuccess("Healed!");
}
// Global cooldown — shared across all players@SubCommand("daily")
@Cooldown(value = 1, unit = TimeUnit.DAYS, global = true)
publicvoidonDaily(CommandContextctx) { ... }
// Custom bypass permission@SubCommand("other")
@Cooldown(value = 5, bypassPermission = "myplugin.admin")
publicvoidonOther(CommandContextctx) { ... }
PlaceholderDescription
<remaining>Human-readable time remaining
<total>Total cooldown duration

Players with core.cooldown.bypass skip all cooldowns automatically. Manual API:

CooldownManagercooldowns = CorePlugin.getInstance().getCooldownManager();
cooldowns.apply(player, "my_action", Duration.ofSeconds(30));
cooldowns.clear(player, "my_action");
cooldowns.getRemaining(player, "my_action");
cooldowns.isOnCooldown(player, "my_action");

Inventory Framework

Creating a GUI

@InventoryGui(id = "main_menu", title = "<dark_gray>✦ Main Menu ✦", rows = 3)
publicclassMainMenuGuiextendsAbstractGui {
@InjectprivateHomeServicehomeService;
@Overrideprotectedvoidbuild(@NotNullGuiBuilderbuilder) {
builder
.border(Material.GRAY_STAINED_GLASS_PANE)
.slot(13,
ItemBuilder.of(Material.NETHER_STAR)
.name("<gold>My Homes")
.lore("<gray>Click to manage homes")
.build(),
event -> {
getViewer().closeInventory();
CorePlugin.getInstance().getInventoryManager()
.open("homes_gui", (Player) event.getWhoClicked());
}
)
.slot(22,
ItemBuilder.of(Material.BARRIER).name("<red>Close").build(),
event -> event.getWhoClicked().closeInventory()
);
}
@OverrideprotectedvoidonOpen(@NotNullPlayerplayer) { ... }
@OverrideprotectedvoidonClose(@NotNullPlayerplayer) { ... }
}

Opening a GUI

CorePlugin.getInstance().getInventoryManager().open("main_menu", player);
MainMenuGuigui = CorePlugin.getInstance().getInventoryManager()
.open(MainMenuGui.class, player);

Refreshing a GUI

CorePlugin.getInstance().getInventoryManager()
.findGui(player.getOpenInventory().getTopInventory())
.ifPresent(AbstractGui::refresh);

GuiBuilder API

builder
.slot(index, item, clickAction) // interactive
.slot(index, item) // decorative
.slotRange(0, 8, fillerItem) // fill range
.fill(Material.BLACK_STAINED_GLASS_PANE) // fill empty slots
.fill(customItem)
.border(Material.GRAY_STAINED_GLASS_PANE) // draw border
.clear(index); // remove slot

Paged GUI

Extend PagedGui for automatic pagination:

@InventoryGui(id = "home_list", title = "<gold>Your Homes", rows = 6)
publicclassHomeListGuiextendsPagedGui {
@InjectprivateHomeServicehomeService;
@OverrideprotectedList<PagedItem> getItems() {
returnhomeService.getHomes(getViewer().getUniqueId()).stream()
.map(home -> PagedItem.of(
ItemBuilder.of(Material.RED_BED).name("<gold>" + home.getName()).build(),
event -> homeService.teleport((Player) event.getWhoClicked(), home)
))
.toList();
}
// Optional overrides@OverrideprotectedList<Integer> getContentSlots() { ... }
@OverrideprotectedvoiddecorateBackground(@NotNullGuiBuilderbuilder) {
builder.border(Material.BLACK_STAINED_GLASS_PANE);
}
@OverrideprotectedItemStackbuildPreviousButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildNextButton(@NotNullPageContextctx) { ... }
@OverrideprotectedItemStackbuildPageIndicator(@NotNullPageContextctx) { ... }
}

For searchable lists, extend SearchablePagedGui and implement getAllItems() instead of getItems():

@InventoryGui(id = "player_list", title = "<aqua>Players", rows = 6)
publicclassPlayerListGuiextendsSearchablePagedGui {
@OverrideprotectedList<PagedItem> getAllItems() { ... }
@OverrideprotectedvoidbuildControls(@NotNullGuiBuilderbuilder, @NotNullPageContextctx) {
super.buildControls(builder, ctx); // prev / indicator / nextbuilder.slot(47, buildSearchButton(), event ->
chatInput.builder(getViewer()).prompt("<gold>Search:").timeout(20)
.request().thenAccept(r -> { if (r.isCompleted()) applyFilter(r.getValue()); })
);
if (hasActiveFilter()) {
builder.slot(48, buildClearFilterButton(), event -> clearFilter());
}
}
}

Navigation methods: nextPage(), previousPage(), goToPage(n), firstPage(), lastPage().


Data Store

Defining a Store

@DataStore(value = "playerdata", directory = "data")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}

Data is stored as plugins/MyPlugin/data/playerdata/<uuid>.dat — binary, XOR-obfuscated, not human-readable.

CRUD Operations

store.put(uuid, data);
store.get(uuid); // Optional<PlayerData>store.remove(uuid); // returns true if removedstore.getAll(); // Map<UUID, PlayerData>store.contains(uuid);
store.size();

TTL / Expiry

store.put(uuid, sessionData, Instant.now().plus(Duration.ofHours(24)));
store.getEntry(uuid).ifPresent(entry -> {
System.out.println("Created: " + entry.getCreatedAt());
System.out.println("Expires: " + entry.getExpiresAt());
System.out.println("Expired: " + entry.isExpired());
});

Custom Key Types

@DataStore("factiondata")
publicclassFactionDataStoreextendsAbstractDataStore<String, FactionData> {
publicFactionDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullStringkey) {
returnkey.toLowerCase().replaceAll("[^a-z0-9_]", "_");
}
@OverrideprotectedStringfileNameToKey(@NotNullStringfileName) { returnfileName; }
}

Event Listeners

@Component@ListenerpublicclassPlayerJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateMainConfigconfig;
@EventHandler(priority = EventPriority.NORMAL)
publicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
event.joinMessage(ComponentUtil.parse(
config.prefix + "<green>" + player.getName() + " joined the game."));
playerService.loadOrCreate(player.getUniqueId(), player.getName());
}
@EventHandlerpublicvoidonQuit(PlayerQuitEventevent) {
playerService.savePlayer(event.getPlayer());
}
}

The framework automatically calls Bukkit.getPluginManager().registerEvents(...) — no manual registration needed.


Module System

Creating a Module

publicclassEconomyModuleextendsAbstractCoreModule {
privatefinalContainercontainer;
publicEconomyModule(Containercontainer) {
super("Economy");
this.container = container;
}
@OverrideprotectedvoidonLoad() {
container.bind(PaymentGateway.class, LocalPaymentGateway.class);
container.bind(EconomyService.class);
}
@OverrideprotectedvoidonEnable() {
SchedulerUtil.repeatAsync(plugin, this::processQueue,
SchedulerUtil.seconds(5), SchedulerUtil.seconds(5));
}
@OverrideprotectedvoidonDisable() {
container.resolve(TransactionLog.class).flush();
}
}

Registering Modules

core.getModuleRegistry().register(newEconomyModule(core.getContainer()));

Lifecycle Order

Registration → loadAll() → enableAll() → [runtime] → disableAll() (reverse order)

Item Builders

All builders use the CRTP pattern — every method returns the most specific builder type, so you never lose the sub-type while chaining.

ItemBuilder

ItemStacksword = ItemBuilder.of(Material.DIAMOND_SWORD)
.name("<gradient:#FF6B6B:#FFE66D>⚔ Excalibur</gradient>")
.lore("<gray>A blade of legend.", "<red>❤ +10 Attack Damage")
.enchant(Enchantment.SHARPNESS, 5)
.enchant(Enchantment.UNBREAKING, 3)
.unbreakable(true)
.hideAllFlags()
.build();
ItemStackfiller = ItemBuilder.filler();
ItemStackredFiller = ItemBuilder.filler(Material.RED_STAINED_GLASS_PANE);

SkullBuilder

SkullBuilder.of().owner(player.getUniqueId()).name("<yellow>Head").build();
SkullBuilder.of().textureBase64("eyJ0ZXh0dXJlcyI6...").build();
SkullBuilder.of().textureUrl("https://textures.minecraft.net/texture/...").build();

LeatherArmorBuilder

LeatherArmorBuilder.of(Material.LEATHER_CHESTPLATE).colorHex("#C0392B").build();
LeatherArmorBuilder.of(Material.LEATHER_BOOTS).color(0, 150, 255).build();
LeatherArmorBuilder.of(Material.LEATHER_HELMET).color(Color.GREEN).build();

BookBuilder

BookBuilder.written()
.title("<gold>Core Manual").author("<gray>mzcy")
.page("<yellow><bold>Welcome!\n\n<reset><gray>Page one content.")
.page("<aqua>Chapter 1: Getting Started")
.build();
BookBuilder.writable().name("<gray>Empty Journal").build();

FireworkBuilder

FireworkBuilder.of()
.power(2)
.effect(FireworkBuilder.FireworkEffectBuilder.create()
.type(FireworkEffect.Type.STAR)
.color(Color.AQUA, Color.WHITE)
.fadeColor(Color.BLUE)
.trail(true).flicker(true).build())
.build();
FireworkBuilder.of().power(3)
.ballEffect(Color.RED, Color.ORANGE)
.starEffect(Color.YELLOW, Color.WHITE)
.build();

Display Systems

Title & Actionbar Manager

// Titles — fluent builderTitleBuilder.create()
.title("<gold><bold>Round Over!")
.subtitle("<gray>You placed <white>3rd")
.fadeIn(Duration.ofMillis(500))
.stay(Duration.ofSeconds(3))
.fadeOut(Duration.ofMillis(500))
.send(player);
// Static shortcutsTitleBuilder.send(player, "<gold>Hello!", "<gray>Welcome back");
TitleBuilder.sendTitle(player, "<gold>Hello!");
TitleBuilder.clear(player);
// Actionbar — priority queue prevents mutual overwritingactionbarManager.set(player, "coords",
"<gray>X: <white>" + loc.getBlockX(), 0); // priority 0 = backgroundactionbarManager.setDynamic(player, "coords",
() -> "<gray>X: <white>" + player.getLocation().getBlockX(), 0);
actionbarManager.sendTemporarySeconds(player, "<green>✔ Saved!", 3, 10);
actionbarManager.clear(player, "coords");
actionbarManager.clearAll(player);

Higher priority messages are shown first. When a temporary message expires, the next highest takes over automatically.

Boss Bar Manager

// Countdown bar — progress automatically decreases to 0bossBarManager.builder(player, "respawn")
.title("<red>Respawning in...")
.color(BossBar.Color.RED)
.overlay(BossBar.Overlay.NOTCHED_10)
.countdown(Duration.ofSeconds(5))
.show();
// Dynamic bar with live updatesbossBarManager.builder(player, "combat")
.dynamicTitle(() -> "<red>⚔ Combat: <white>" + remaining + "s")
.dynamicProgress(() -> combatService.getRemainingFraction(player))
.color(BossBar.Color.RED)
.duration(Duration.ofSeconds(30))
.show();
// Permanent notificationbossBarManager.builder(player, "event")
.title("<gold>⚡ Double XP Weekend Active!")
.color(BossBar.Color.YELLOW)
.progress(1.0f)
.show();
bossBarManager.hide(player, "combat");
bossBarManager.hideAll(player);
bossBarManager.has(player, "combat");

Scoreboard Framework

Per-player sidebars with dynamic lines, dirty-check updates (no flicker), auto-show on join:

scoreboardManager.register("main",
SidebarBuilder.create("<gradient:#FFD700:#FFA500><bold>MyServer</gradient>")
.line("<dark_gray>━━━━━━━━━━━━━━━")
.blank()
.dynamic(() -> "<gray>Players<dark_gray>: <white>"
+ Bukkit.getOnlinePlayers().size())
.dynamic(() -> "<gray>TPS<dark_gray>: <white>"
+ String.format("%.1f", Math.min(20.0, Bukkit.getTPS()[0])))
.blank()
.line("<gray>play.myserver.net")
.build(plugin)
);
scoreboardManager.setDefault("main"); // auto-shown to all players on joinscoreboardManager.startUpdating("main", 20L);
scoreboardManager.show(player, "vip"); // switch sidebar for one playerscoreboardManager.hide(player);
// Dynamic titlescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setTitle("<red>Server Restarting!"));
// Update a specific linescoreboardManager.getSidebar("main")
.ifPresent(s -> s.setLine(2, "<yellow>Players: " + count));

Hologram Framework

Uses modern Display entities (1.19.4+) — TextDisplay, ItemDisplay, BlockDisplay. No ArmorStands:

// Text + item mixed hologramhologramManager.builder("shop_sign", location)
.item(newItemStack(Material.DIAMOND),
ItemDisplay.ItemDisplayTransform.GROUND, 1.2f)
.text("<aqua><bold>Diamond Shop")
.text("<gray>Right-click the NPC to browse")
.dynamicText(() -> "<yellow>Stock: <white>" + shop.getStock())
.lineSpacing(0.08)
.spawn();
// Block display hologramhologramManager.builder("beacon_holo", location)
.block(Material.BEACON.createBlockData(), 0.6f, 45f)
.text("<light_purple>Beacon Active")
.dynamicText(() -> "<gray>Range: <white>" + beaconService.getRange() + " blocks")
.spawn();
// Dynamic leaderboardhologramManager.builder("kills_lb", location)
.text("<red><bold>⚔ Kill Leaderboard")
.text("<dark_gray>──────────────")
.dynamicText(() -> "<gold>#1 <white>" + lb.getTop(1).getName()
+ " <gray>— <red>" + lb.getTop(1).getKills())
.dynamicText(() -> "<gray>#2 <white>" + lb.getTop(2).getName())
.spawn();
// Mutate after spawnhologramManager.get("shop_sign")
.flatMap(h -> h.getTextLine(2))
.ifPresent(line -> line.setText("<green>Open Now"));
hologramManager.setUpdateInterval(10L); // update every 0.5shologramManager.remove("shop_sign");
hologramManager.removeAll();

NPC Framework

Citizens-free NPC system using ArmorStand proxies with Display entity hologram labels:

npcManager.builder("shop_keeper")
.name("<gold><bold>Shop Keeper")
.location(newLocation(world, 0.5, 64, 0.5))
.texture(TEXTURE_VALUE, TEXTURE_SIGNATURE)
.hologram("<gold><bold>Shop Keeper", "<gray>Right-click to browse")
.lookAtPlayer(true)
.lookAtDistance(6.0)
.viewDistance(48)
.glowing(false)
.collidable(false)
.onClick((player, npc, type) -> {
if (type == NpcClickType.RIGHT_CLICK) {
inventoryManager.open("shop_gui", player);
}
})
.spawn();
// Dynamic hologram updatenpcManager.get("shop_keeper").ifPresent(npc ->
npc.updateHologram(List.of(
"<gold><bold>Shop Keeper",
"<gray>Items: <white>" + shop.getItemCount(),
shop.isOpen() ? "<green>Open" : "<red>Closed"
))
);
npcManager.despawn("shop_keeper");
npcManager.despawnAll();
npcManager.count();

Interaction Systems

Chat Input Handler

chatInputManager.builder(player)
.prompt("<gold>Enter your home name:")
.cancelKeyword("cancel")
.cancelMessage("<gray>Cancelled.")
.timeoutMessage("<red>Timed out.")
.timeout(Duration.ofSeconds(30))
.closeInventory(true)
.validator(
InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16))
)
.request()
.thenAccept(result -> {
switch (result.getStatus()) {
caseCOMPLETED -> homeService.createHome(player, result.getValue());
caseCANCELLED -> player.sendMessage("<gray>Cancelled.");
caseTIMED_OUT -> player.sendMessage("<red>Too slow!");
caseDISCONNECTED -> log.info(player.getName() + " disconnected.");
}
});

Built-in validators: notBlank(), maxLength(n), minLength(n), alphanumeric(), integer(), integerInRange(min, max), positiveDecimal(), matches(regex, msg), onlinePlayer(). Combine with .and().

Form System

Multi-step sequential input forms:

Formform = Form.builder("create_home")
.title("<gold>Create Home")
.field(FormField.builder("name")
.prompt("<gold>Home name:")
.placeholder("e.g. base, farm")
.validator(InputValidator.Validators.alphanumeric()
.and(InputValidator.Validators.maxLength(16)))
.build()
)
.field(FormField.builder("icon")
.prompt("<gold>Icon material:")
.required(false)
.defaultValue("RED_BED")
.build()
)
.field(FormField.builder("confirm")
.prompt("<yellow>Confirm? (yes/no)")
.inputType(FormField.InputType.CONFIRM)
.build()
)
.onSubmit(response -> {
if (response.isConfirmed("confirm")) {
homeService.createHome(player,
response.get("name"), response.get("icon"));
}
})
.onCancel(r -> player.sendMessage("<gray>Cancelled at: " + r.getCancelledAtField()))
.build();
formManager.register(form);
formManager.open("create_home", player);
// Inline (no registration needed)formManager.open(form, player).thenAccept(response -> { ... });

FormResponse methods: get(key), getInt(key), getDouble(key), isConfirmed(key), isSubmitted(), isCancelled(), getCancelledAtField().

Menu System

Lightweight chat-based context menus — no inventory needed. Players click text or type a number:

ContextMenu.builder("home_options")
.title("<gold>Home Options — " + home.getName())
.item(MenuItem.of("<green>⚡ Teleport",
List.of("<gray>Teleport to this home"),
(p, m) -> homeService.teleport(p, home)))
.item(MenuItem.of("<yellow>✏ Rename",
(p, m) -> formManager.open("rename_home", p)))
.item(MenuItem.separator())
.item(MenuItem.of("<red>✗ Delete",
List.of("<gray>This cannot be undone"),
(p, m) -> confirmAndDelete(p, home)))
.item(MenuItem.submenu("<aqua>More Options",
buildMoreMenu(player)))
.timeout(30L)
.build()
.open(player);

Output in chat:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Home Options — base
▸ [1] ⚡ Teleport
│ Teleport to this home
▸ [2] ✏ Rename
──────────────
▸ [3] ✗ Delete
│ This cannot be undone
▸ [4] More Options ▶
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Click an option or type its number.

MenuItem types: ACTION, SEPARATOR, DISABLED, SUBMENU.

Conversation System

Branching NPC dialogue trees with conditions, actions, and player choice:

ConversationTreetree = ConversationTree.builder("guard_intro")
.npcName("<gold>⚔ Guard")
.startNode("greeting")
.dialogue("greeting", List.of(
"<gold>[Guard]</gold> <white>Halt! State your business."
), "check_rank")
.condition("check_rank",
(player, ctx) -> rankService.hasRank(player, "citizen"),
"citizen_path", "stranger_path"
)
.dialogue("citizen_path", List.of(
"<gold>[Guard]</gold> <white>Welcome home, citizen."
), "choice")
.choice("choice", null, List.of(
ConversationChoice.of("quest", "Any work available?", "offer_quest"),
ConversationChoice.of("bye", "Farewell.", "end_friendly")
))
.action("offer_quest",
(p, ctx) -> {
questService.assign(p, "wolf_hunt");
ctx.setFlag("quest_accepted");
},
"quest_end"
)
.end("quest_end", "<gold>[Guard]</gold> <green>Good luck.")
.end("end_friendly", "<gold>[Guard]</gold> <white>Safe travels.")
.end("stranger_path","<gold>[Guard]</gold> <red>Move along.")
.build();
conversationManager.register(tree);
// Start from NPC clickconversationManager.start("guard_intro", player)
.thenAccept(ctx -> {
if (ctx.hasFlag("quest_accepted")) {
player.sendMessage("<green>Quest added to your journal!");
}
});

Node types: DIALOGUE, CHOICE, ACTION, CONDITION, END. Choices support visibleIf conditions. ConversationContext provides key-value data store and flag system between nodes.


Task Pipeline

Fluent async/sync task chains with automatic thread-switching mid-chain:

taskManager.chain()
.asyncSupply(() -> database.loadPlayerData(uuid)) // async DB call
.syncConsume((ctx, data) -> { // → main threadctx.set("data", data);
player.sendMessage("<green>Profile loading...");
})
.async((ctx, data) -> enrichData(data)) // → async thread
.delay(40L) // wait 2 seconds (sync)
.syncConsume((ctx, data) -> openMainMenu(player)) // → main thread
.onError(ex -> player.sendMessage("<red>Error: " + ex.getMessage()))
.onComplete(ctx -> log.info("Chain completed."))
.onCancel(ctx -> log.info("Chain cancelled."))
.execute(); // returns CompletableFuture<TaskContext>

Guard clauses mid-chain:

.sync((ctx, balance) -> {
if (balance < price) { ctx.cancel(); }
returnbalance;
})
.onCancel(ctx -> player.sendMessage("<red>Not enough money."))

Scheduled tasks via @Task — auto-discovered on boot:

@ComponentpublicclassBackupService {
@Task(name = "DB Backup", async = true, repeat = true,
delay = 6000L, period = 72000L)
publicvoidrunBackup() { database.backup(); }
@Task(name = "Startup Check", async = true, delay = 40L)
publicvoidstartupCheck() { database.verifySchema(); }
}

Manual scheduling:

taskManager.schedule("leaderboard_update", true, 0L, 200L,
leaderboardService::rebuild);
taskManager.cancelTask("leaderboard_update");
taskManager.cancelAll();

Loot Table System

Weighted pool-based drop system with conditions and @LootTableDef auto-registration:

@ComponentpublicclassModLootTables {
@LootTableDefpublicLootTablezombieDrops() {
returnLootTable.builder("zombie_drops")
.pool(LootPool.builder("common")
.rolls(1, 3)
.bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.ROTTEN_FLESH), 80, 1, 4))
.entry(LootEntry.of(newItemStack(Material.BONE), 30, 1, 2))
.entry(LootEntry.empty(20))
.build()
)
.pool(LootPool.builder("rare")
.rolls(1)
.condition(LootCondition.LootConditions.chance(0.025))
.entry(LootEntry.of(newItemStack(Material.IRON_INGOT), 100))
.build()
)
.build();
}
@LootTableDefpublicLootTablebossDrops() {
returnLootTable.builder("boss_drops")
.pool(LootPool.builder("guaranteed")
.rolls(1)
.entry(LootEntry.of(
ItemBuilder.of(Material.NETHER_STAR).name("<light_purple>Boss Soul").build(),
100))
.build()
)
.pool(LootPool.builder("bonus")
.rolls(2, 4).bonusRollsPerLooting(1)
.entry(LootEntry.of(newItemStack(Material.DIAMOND), 40, 1, 3))
.entry(LootEntry.dynamic(() -> enchantRandomly(newItemStack(Material.IRON_SWORD)), 20))
.entry(LootEntry.empty(10))
.build()
)
.build();
}
}

Rolling API:

// Roll for a player (auto-detects looting enchantment level)lootManager.rollForPlayer("zombie_drops", player);
// Roll and drop items at a world locationlootManager.rollAndDrop("boss_drops", player, entity.getLocation());
// Give directly to player's inventory (overflow drops at feet)lootManager.rollAndGive("daily_reward", player);
// Custom context with luck modifierLootContextctx = LootContext.builder()
.player(player).lootingLevel(3).luck(0.5).build();
List<ItemStack> items = lootManager.roll("boss_drops", ctx);

Built-in conditions: chance(p), minLevel(n), hasPermission(node), hasFlag(flag), isDaytime(), hasEnchantment(ench), lootingLevel(n). All support .and(), .or(), .negate().


Particle System

// One-shot static effectsParticleUtil.burst(location, Particle.EXPLOSION, 10);
ParticleUtil.ring(location, 2.0, 32, Particle.END_ROD);
ParticleUtil.dust(location, Color.fromRGB(255, 87, 51), 1.5f, 8);
ParticleUtil.sphere(location, 3.0, 60, Particle.END_ROD);
ParticleUtil.line(from, to, 20, Particle.FLAME);
// Typed effects with genericsParticleEffect.dust(Color.AQUA, 1.5f).spawn(location);
ParticleEffect.dustTransition(Color.RED, Color.BLUE, 2.0f).spawn(location);
ParticleEffect.block(Material.DIAMOND_BLOCK).withCount(15).spawn(location);
ParticleEffect.of(Particle.HEART, 3).withOffset(0.3).spawn(player, location);
// Animated effectsParticleAnimatorhelix = ParticleUtil.helix(plugin, location, Particle.END_ROD, 100L);
ParticleAnimatorpulse = ParticleUtil.pulse(plugin, location, Particle.TOTEM_OF_UNDYING, 40L);
// Custom animator — full controlnewParticleAnimator(plugin)
.interval(1L).loop(true)
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.AQUA, 1.5f),
() -> ParticleShape.star(center, 2.0, 1.0, 6)
))
.step(ParticleAnimation.of(
ParticleEffect.dust(Color.WHITE, 1.0f),
() -> ParticleShape.circle(center, 2.5, 24)
))
.onComplete(a -> player.sendMessage("Done!"))
.start();

Available shapes: circle, disk, line, sphere (Fibonacci lattice), cylinder, helix, star, wave.


PlaceholderAPI Integration

Soft-dependency — gracefully no-ops if PAPI is not installed. Add to plugin.yml:

softdepend:
- PlaceholderAPI
// Option A — implement PlaceholderProvider on any @Component (auto-discovered)@ComponentpublicclassEconomyPlaceholdersimplementsPlaceholderProvider {
@InjectprivateEconomyServiceeconomy;
@OverridepublicvoidregisterPlaceholders(@NotNullPlaceholderRegistryregistry) {
registry
.register("balance", player ->
String.valueOf(economy.getBalance(player.getUniqueId())))
.registerGlobal("total_players", () ->
String.valueOf(Bukkit.getOnlinePlayers().size()))
.registerStatic("currency_symbol", "€")
.registerOnline("player_name", player -> player.getName());
}
}
// Option B — manual registrationCorePlugin.getInstance().getPlaceholderManager()
.getRegistry().register("my_key", player -> "hello!");
// Resolve PAPI placeholders in a stringStringmsg = placeholderManager.setPlaceholders(player, "Balance: %core_balance%");
// Resolve PAPI + parse MiniMessage in one callComponentcomp = placeholderManager.parseWithPlaceholders(
player, "<gold>Balance: <white>%core_balance%");

Built-in placeholders: %core_version%, %core_online%, %core_max_players%, %core_tps%, %core_uptime%, %core_player_name%, %core_player_uuid%, %core_player_online%.

Registration styles: register() (player-aware), registerGlobal() (same for all), registerStatic() (fixed value), registerOnline() (null-safe player).


Update Checker

Checks https://github.com/mzcydev/paper-core releases via the GitHub API. All network I/O is async.

// One-liner — async, logs to console automaticallynewUpdateChecker(this).checkAsync();
// With callback (runs on main thread)newUpdateChecker(this).checkAsync(result -> {
if (result.isUpdateAvailable()) {
log.warning("Update available: " + result.getLatestVersion());
log.warning("Download: " + result.getReleaseUrl());
// UpdateNotifier auto-notifies ops on join when registered
}
});
// Sync (blocks calling thread — use only off main thread)UpdateResultresult = newUpdateChecker(this).checkSync();

Statuses: UPDATE_AVAILABLE, UP_TO_DATE, DEV_BUILD, FAILED. Full SemVer comparison: 1.2.3, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3-rc.1, leading v stripped automatically.


Hot-Reload

Reload configs and listeners without restarting the server. Triggered via /core reload.

// Annotate any @Component method — auto-discovered@ComponentpublicclassShopService {
@InjectprivateShopConfigconfig;
@Reloadable(name = "Shop Service", order = 20)
publicvoidreload() {
config.reload();
rebuildCache();
}
}
// Register a manual stephotReloadManager.addStep("Economy Cache", () -> {
economyService.flushCache();
economyService.warmCache();
});
// Trigger programmaticallyReloadResultresult = hotReloadManager.reload(sender);
switch (result.getStatus()) {
caseSUCCESS -> sender.sendMessage("All " + result.totalSteps() + " steps succeeded.");
casePARTIAL -> result.getFailedSteps().forEach(s -> log.warn("Failed: " + s));
caseALREADY_RUNNING -> sender.sendMessage("A reload is already in progress.");
}

Reload phases (in order):

  1. Unregister all managed Bukkit listeners
  2. Execute built-in steps (Configs.reloadAll(), listener re-registration)
  3. Execute registered ReloadSteps
  4. Execute @Reloadable methods sorted by order
  5. Re-register all listeners
  6. Re-inject all singleton fields (new config values propagate automatically)

Debug Overlay

/core debug → Full overlay in chat (color-coded)
/core debug paste → Upload report to pastes.dev, returns clickable URL
/core bindings → List all DI container bindings with scope + live status
/core gc → Request JVM GC + show freed memory delta
/core version → Core version, Paper version, Java version
/core reload → Trigger hot-reload

Built-in sections: JVM (heap, uptime, Java version, CPUs), Server (TPS×3 color-coded, players, ticks, worlds), * DI Container* (binding count, live singletons), Configs (files + existence), DataStores (per-store entry count), Inventories (open GUIs, registered types), Scoreboard, NPC, PlaceholderAPI.

Add custom entries via @Debug on any @Component:

@Component@DebugpublicclassShopDebugInfo {
@InjectprivateShopServiceshopService;
@Debug(category = "Shop", label = "Active Listings")
publicStringlistings() {
returnString.valueOf(shopService.countActiveListings());
}
@Debug(category = "Shop", label = "Revenue Today")
publicStringrevenue() {
return"<gold>" + shopService.getRevenue();
}
@Debug(category = "Shop", label = "Category Breakdown")
publicMap<String, Integer> breakdown() {
returnshopService.getCountByCategory(); // Map is formatted automatically
}
}
// Manual registrationcore.getDebugOverlay().getRegistry()
.registerEntry("Custom", "Server Status", () -> myService.getStatus());

The paste upload uses pastes.dev API — plain text, all MiniMessage tags stripped, metadata header with timestamp and generator included.


Utilities

SchedulerUtil

SchedulerUtil.run(plugin, () -> player.sendMessage("Hello!"));
SchedulerUtil.runLater(plugin, () -> teleport(player), SchedulerUtil.seconds(3));
BukkitTasktask = SchedulerUtil.repeat(plugin, this::tick, 0L, SchedulerUtil.seconds(5));
SchedulerUtil.runAsync(plugin, () -> heavyComputation());
SchedulerUtil.runLaterAsync(plugin, () -> dbWrite(), SchedulerUtil.seconds(1));
SchedulerUtil.supplyAsync(plugin, () -> database.findPlayer(uuid))
.thenAcceptAsync(data -> player.sendMessage("Balance: " + data.getBalance()),
SchedulerUtil.syncExecutor(plugin));
SchedulerUtil.cancel(task);
SchedulerUtil.seconds(5); // → 100 ticksSchedulerUtil.minutes(1); // → 1200 ticks

ComponentUtil

ComponentUtil.parse("<red>Hello <bold>World");
ComponentUtil.parse("<prefix> Welcome, <player>!",
Map.of("prefix", "<dark_gray>[Core]", "player", player.getName()));
ComponentUtil.fromLegacy("&aHello &bWorld");
ComponentUtil.toLegacy(component);
ComponentUtil.toMiniMessage(component);
ComponentUtil.toPlain(component);
ComponentUtil.stripFormatting("<red><bold>Hello"); // → "Hello"ComponentUtil.parseList(List.of("<red>Line 1", "<green>Line 2"));
ComponentUtil.empty();
ComponentUtil.newline();
ComponentUtil.plain("No formatting");

ColorUtil

ColorUtil.fromHex("#FF5733");
ColorUtil.fromHex("33FF57"); // # is optionalColorUtil.fromHex("#00F"); // shorthand expands to #0000FFColorUtil.fromHexSafe("#invalid"); // returns null on failureColorUtil.fromRgb(255, 87, 51);
ColorUtil.toTextColor(bukkit);
ColorUtil.toBukkitColor(textColor);
ColorUtil.toHex(color); // → "#FF5733"ColorUtil.lerp(Color.RED, Color.BLUE, 0.5f);
ColorUtil.gradient(Color.RED, Color.YELLOW, 10);
ColorUtil.gradientText("Hello World", "#FF0000", "#0000FF");
ColorUtil.rainbowText("Rainbow!", 0);

TimeUtil

TimeUtil.format(Duration.ofSeconds(3661)); // → "1h 1m 1s"TimeUtil.format(Duration.ofSeconds(90)); // → "1m 30s"TimeUtil.formatSeconds(45); // → "45s"TimeUtil.formatUntil(Instant.now().plusSeconds(120)); // → "2m 0s"TimeUtil.parse("1h30m"); // → Duration.ofMinutes(90)TimeUtil.parse("2d12h"); // → Duration.ofHours(60)TimeUtil.parseSafe("bad_input"); // → Duration.ZEROTimeUtil.toTicks(Duration.ofSeconds(5)); // → 100TimeUtil.fromTicks(200); // → 10 seconds

SoundUtil

// Presets — zero allocation, all static finalSoundUtil.play(player, SoundUtil.Presets.SUCCESS);
SoundUtil.play(player, SoundUtil.Presets.CLICK);
SoundUtil.play(player, SoundUtil.Presets.ERROR);
SoundUtil.playAll(players, SoundUtil.Presets.PING);
SoundUtil.broadcast(server, SoundUtil.Presets.LEVEL_UP);
SoundUtil.stop(player, Sound.MUSIC_GAME);
SoundUtil.stopAll(player);
// Custom effectSoundEffect.of(Sound.ENTITY_DRAGON_DEATH, 0.5f, 1.2f).play(player);
SoundEffect.builder()
.sound(Sound.UI_BUTTON_CLICK).volume(0.6f).pitch(1.5f)
.category(SoundCategory.MASTER).build()
.playAt(location);
// Timed sequencesSoundSequence.create()
.then(SoundUtil.Presets.TICK, 0L)
.then(SoundUtil.Presets.TICK, 20L)
.then(SoundUtil.Presets.TICK_FINAL, 40L)
.then(SoundUtil.Presets.TELEPORT_OUT, 60L)
.play(plugin, player);

Available presets: CLICK, CLICK_SOFT, CLICK_HIGH, SUCCESS, ERROR, WARNING, LEVEL_UP, COIN, PURCHASE, OPEN, CLOSE, PAGE_PREV, PAGE_NEXT, PING, TICK, TICK_FINAL, TELEPORT_OUT, TELEPORT_IN, DEPOSIT, WITHDRAW.

Preconditions

Preconditions.notNull(player, "Player must not be null");
Preconditions.notBlank(input, "Name must not be blank");
Preconditions.isTrue(balance >= 0, "Balance cannot be negative");
Preconditions.isFalse(banned, "Banned players cannot do this");
Preconditions.inRange(index, 0, 53, "Slot out of range");
Preconditions.notEmpty(homeList, "Home list must not be empty");

Annotation Reference

AnnotationTargetPurpose
@ComponentClassRegister as DI-managed component
@SingletonClassExplicit singleton scope (default)
@PrototypeClassNew instance per injection point
@InjectField / Constructor / MethodMark injection point
@Named("id")Field / ParameterQualify injection by name
@PostConstructMethodCalled after all fields injected
@PreDestroyMethodCalled before container destroys instance
@Config(...)ClassDeclare a config file binding
@Command(...)ClassRegister a command handler
@SubCommand(...)MethodRegister a sub-command on a BaseCommand
@Cooldown(...)Method / ClassApply cooldown to a command or sub-command
@ListenerClassAuto-register as Bukkit event listener
@DataStore(...)ClassRegister a binary data store
@InventoryGui(...)ClassRegister a GUI inventory
@Task(...)MethodSchedule a recurring or one-shot task
@Reloadable(...)MethodInvoked during hot-reload
@Debug(...)Method / ClassExpose info to /core debug
@LootTableDefMethodRegister a LootTable factory method

Exception Hierarchy

CoreException (RuntimeException)
├── ModuleException — thrown during module load/enable
├── InjectionException — thrown during DI resolution or injection
├── ConfigException — thrown during config load/save
├── CommandException — thrown during command registration or dispatch
├── DataStoreException — thrown during store I/O
└── InventoryException — thrown during GUI build or open

All exceptions are unchecked and carry a descriptive message including the failing component name.


Boot Sequence

onEnable()
│
├─ [1] Container construction
│ Self-registers: Plugin, Server, Logger, Path
│ Constructs and binds all framework managers
│
├─ [2] ClassScanner → ScanResult
│ Scans JAR for all annotated types
│ AnnotationProcessor wires everything into Container
│
├─ [3] ConfigManager.initializeAll()
│ Wires each @Config with file path + adapter
│ Copies defaults from JAR if missing, calls load()
│
├─ [4] DataStoreManager.initializeAll()
│ Creates store directories, loads .dat files into cache
│
├─ [5] CommandManager.registerAll()
│ Registers each @Command into Paper's CommandMap
│ Wires CooldownManager into each BaseCommand instance
│
├─ [6] InventoryManager.initializeAll()
│ Registers @InventoryGui types by ID
│ Registers GuiListener for click/drag/close
│
├─ [7] PlaceholderManager.initialize()
│ Detects PAPI, registers expansion, discovers PlaceholderProviders
│
├─ [8] Bukkit listener registration
│ Calls registerEvents() for all @Listener components
│
├─ [9] LootManager.discoverAndRegister()
│ Finds and calls all @LootTableDef factory methods
│
├─ [10] TaskManager.discoverAndSchedule()
│ Finds and schedules all @Task methods
│
├─ [11] DebugOverlay.discoverFrom()
│ Finds all @Debug-annotated components
│
├─ [12] UpdateChecker.checkAsync()
│ Async GitHub API check, registers UpdateNotifier if update found
│
├─ [13] ModuleRegistry.loadAll()
│ Calls load() on all registered modules in order
│
└─ [14] ModuleRegistry.enableAll()
Calls enable() on all registered modules in order
onDisable()
├─ ModuleRegistry.disableAll() (reverse order)
├─ ConversationManager.shutdown()
├─ FormManager.shutdown()
├─ MenuManager.shutdown()
├─ ChatInputManager.shutdown()
├─ TaskManager.cancelAll()
├─ NpcManager.destroy()
├─ HologramManager.destroy()
├─ ScoreboardManager.destroyAll()
├─ BossBarManager.shutdown()
├─ ActionbarManager.shutdown()
├─ InventoryManager.closeAll()
├─ CommandManager.unregisterAll()
├─ PlaceholderManager.shutdown()
├─ DataStoreManager.flushAll()
├─ ConfigManager.saveAll()
└─ Container.destroy() (@PreDestroy + clear all bindings)

Full Example Plugin

// MyPlugin.javapublicfinalclassMyPluginextendsJavaPlugin {
@OverridepublicvoidonEnable() {
finalCorePlugincore = CorePlugin.getInstance();
core.getComponentRegistry().scanAndProcess("dev.mzcy.example");
core.getConfigManager().initializeAll(core.getScanResult());
core.getDataStoreManager().initializeAll(core.getScanResult());
core.getCommandManager().registerAll(core.getScanResult());
core.getInventoryManager().initializeAll(core.getScanResult());
}
}
// ExampleConfig.java@Config("config")
publicclassExampleConfigextendsAbstractConfig {
publicStringwelcomeMessage = "<green>Welcome, <player>!";
publicintstartBalance = 100;
}
// PlayerDataStore.java@DataStore("players")
publicclassPlayerDataStoreextendsAbstractDataStore<UUID, PlayerData> {
publicPlayerDataStore() { super(newBinaryDataSerializer<>()); }
@OverrideprotectedStringkeyToFileName(@NotNullUUIDkey) { returnkey.toString(); }
@OverrideprotectedUUIDfileNameToKey(@NotNullStringf) { returnUUID.fromString(f); }
}
// PlayerService.java@ComponentpublicclassPlayerService {
@InjectprivatePlayerDataStorestore;
@InjectprivateExampleConfigconfig;
publicvoidonJoin(Playerplayer) {
if (!store.contains(player.getUniqueId())) {
store.put(player.getUniqueId(),
newPlayerData(player.getName(), config.startBalance));
}
}
publicintgetBalance(UUIDuuid) {
returnstore.get(uuid).map(PlayerData::getBalance).orElse(0);
}
@Reloadable(name = "Player Cache", order = 10)
publicvoidreload() {
// Cache rebuild after hot-reload
}
}
// JoinListener.java@Component@ListenerpublicclassJoinListenerimplementsListener {
@InjectprivatePlayerServiceplayerService;
@InjectprivateExampleConfigconfig;
@EventHandlerpublicvoidonJoin(PlayerJoinEventevent) {
finalPlayerplayer = event.getPlayer();
playerService.onJoin(player);
player.sendMessage(ComponentUtil.parse(
config.welcomeMessage,
Map.of("player", player.getName())
));
}
}
// BalanceCommand.java@Command(name = "balance", aliases = {"bal"},
permission = "example.balance", playerOnly = true)
publicclassBalanceCommandextendsBaseCommand {
@InjectprivatePlayerServiceplayerService;
@OverrideprotectedvoidonCommand(@NotNullCommandContextctx) {
finalintbalance = playerService.getBalance(ctx.playerOrThrow().getUniqueId());
ctx.send("<gold>Your balance: <white>" + balance + " coins");
}
@SubCommand("set")
@Cooldown(value = 5, unit = TimeUnit.SECONDS, bypassPermission = "example.admin")
publicvoidonSet(CommandContextctx) {
ctx.argInt(0).ifPresent(amount -> {
playerService.setBalance(ctx.playerOrThrow().getUniqueId(), amount);
ctx.sendSuccess("Balance set to <white>" + amount);
});
}
}

License

MIT License — Copyright (c) 2026 mzcy_ and contributors.

Built with ❤ for the Paper ecosystem.

About

A professional, annotation-driven plugin framework for Paper 1.21.x built around dependency injection, automatic component scanning, and a clean module lifecycle.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages