Modules
BetterPlugin ships a few small API modules that you can use with any Paper plugin, whether or not you extend PluginBase.
All modules are plain Java classes; you can create them anywhere in your plugin. If you extend PluginBase, some helpers are already integrated (see Plugin entry).
Scheduler
TaskScheduler schedules tasks for one plugin and lets you cancel them all at once.
import org.coffeepop.betterPlugin.api.scheduler.TaskScheduler;
public final class MyPlugin extends JavaPlugin {
private TaskScheduler scheduler;
@Override
public void onEnable() {
scheduler = new TaskScheduler(this);
// Run once on the main thread.
scheduler.runSync(() -> getLogger().info("started"));
// Run on a background thread.
scheduler.runAsync(() -> fetchRemoteData());
// Run later on the main thread.
scheduler.runSyncLater(() -> getLogger().info("one second later"), 20L);
// Repeat every 5 minutes on the main thread.
scheduler.runSyncTimer(() -> saveData(), 0L, 20L * 60 * 5);
// Repeat on a background thread.
scheduler.runAsyncTimer(() -> fetchRemoteData(), 0L, 20L * 60);
}
@Override
public void onDisable() {
// Stops every task owned by this scheduler.
scheduler.cancelAll();
}
}Practical notes
- If you extend
PluginBase, usetasks()instead of creating aTaskScheduleryourself. It is cancelled automatically on plugin disable. - Always call
cancelAll()on plugin disable when you create a standalone scheduler. If you don't, repeating tasks keep running after your plugin is disabled. cancelAll()closes the scheduler. After calling it, any task created through the same scheduler is cancelled immediately. Create a newTaskSchedulerif you need to schedule again after a reload.- Background tasks must not touch Bukkit API that requires the main thread. For example, opening inventories, modifying worlds, or sending packets usually must happen on the main thread. Use
runSync/runSyncTimerfor those. - Task callbacks run on whatever thread the scheduler picks. Keep data shared between threads in thread-safe containers or synchronize explicitly.
Config
PluginConfig is a typed wrapper around config.yml.
import org.coffeepop.betterPlugin.api.config.PluginConfig;
import java.util.List;
public final class MyPlugin extends JavaPlugin {
private PluginConfig config;
@Override
public void onEnable() {
config = new PluginConfig(this, this::reloadSettings);
// Read values with defaults.
int interval = config.getInt("auto-save.interval-seconds", 300);
boolean announce = config.getBoolean("join-announcement.enabled", true);
String prefix = config.getString("message-prefix", "[MyPlugin] ");
List<String> greetings = config.getStringList("greetings");
// Reload manually when a server operator asks for it.
config.reload();
}
private void reloadSettings() {
// Called after every reload; rebuild timers or cached values here.
}
}Practical notes
reload()applies missing defaults first, then reloads from disk. It never overwrites values the server operator has changed.- After
reload(), read values again throughPluginConfig. Do not keep long-lived copies of primitive settings if you support reload. - The reload callback runs on the thread that called
reload(). If you rebuild scheduler tasks inside it, make sure you are on the main thread (or delegate withTaskScheduler.runSync). - If your plugin has no
config.yml,reload()is safe. It simply reloads the (empty) config and runs the callback.
Placeholders
PlaceholderFormatter replaces {key} tokens in a template.
import org.coffeepop.betterPlugin.api.utils.PlaceholderFormatter;
import java.util.Map;
String message = PlaceholderFormatter.format(
"Hello {player}, you have {amount} items.",
Map.of(
"player", player.getName(),
"amount", String.valueOf(amount)
)
);Practical notes
- Replacement is single-pass. If a replacement value contains
{another}, that text is kept literally and not replaced again. - Unknown placeholders are left as-is. That is usually better for debugging than silently removing them.
- Build a
Mapfor each message. It is cheap and keeps the code readable; do not mutate the map while formatting.
Events
ListenerRegistry registers event handlers without writing annotated Listener classes.
import org.coffeepop.betterPlugin.api.event.ListenerRegistry;
import org.bukkit.event.player.PlayerJoinEvent;
public final class MyPlugin extends JavaPlugin {
private ListenerRegistry listeners;
@Override
public void onEnable() {
listeners = new ListenerRegistry(this);
listeners.register(PlayerJoinEvent.class, event ->
event.getPlayer().sendPlainMessage("Welcome!"));
// You can register as many handlers as you want.
listeners.register(PlayerQuitEvent.class, event ->
getLogger().info(event.getPlayer().getName() + " left"));
}
@Override
public void onDisable() {
listeners.unregisterAll();
}
}Practical notes
- If you extend
PluginBase, uselisteners()instead of creating aListenerRegistryyourself. It is unregistered automatically on plugin disable. - Call
unregisterAll()on plugin disable when you use a standalone registry. Otherwise handlers keep firing for a disabled plugin. - Handlers run on the thread that fired the event. Most Bukkit events fire on the main thread, but async events (for example, async chat/player login events) do not. Check the event's documentation before touching shared state.
- Do not run long work inside an event handler. If you need to save data or call a web service, schedule it with
TaskScheduler.runAsyncand return quickly. ListenerRegistryis reusable. AfterunregisterAll()you can register new handlers again; it creates a fresh internal listener.
Items
ItemBuilder creates ItemStacks with display names and lore.
import org.coffeepop.betterPlugin.api.item.ItemBuilder;
import org.bukkit.Material;
ItemStack sword = ItemBuilder.of(Material.DIAMOND_SWORD)
.amount(1)
.name("Hero Sword")
.lore("Legendary", "Right-click to activate")
.build();
// You can also use Adventure components directly.
ItemStack fancy = ItemBuilder.of(Material.PAPER)
.name(Component.text("Notice").color(NamedTextColor.GOLD))
.lore(List.of(Component.text("Line 1"), Component.text("Line 2")))
.build();Practical notes
- Build items once and reuse them when possible. Creating item meta repeatedly is wasteful; cache static items in fields.
amount(int)must be positive. The builder rejects zero and negative amounts.- Lore lines are appended. Calling
lore(...)twice adds both sets of lines; call it once with all lines if you want a single list. - The builder does not apply enchantments, flags, or damage yet. Use
item.getItemMeta()directly afterbuild()for advanced meta.
GUI
InventoryGui is a minimal inventory menu helper.
import org.coffeepop.betterPlugin.api.gui.InventoryGui;
import org.coffeepop.betterPlugin.api.item.ItemBuilder;
import org.bukkit.Material;
InventoryGui gui = InventoryGui.builder(this, 9, "Menu")
.item(0, ItemBuilder.of(Material.DIAMOND).name("Click me").build(), event ->
event.getWhoClicked().sendPlainMessage("You clicked diamond!"))
.item(4, ItemBuilder.of(Material.BARRIER).name("Close").build(), event ->
event.getWhoClicked().closeInventory())
.build();
// Open for a player.
gui.open(player);
// Later, update an item while the GUI is open.
gui.setItem(0, ItemBuilder.of(Material.EMERALD).name("Changed").build());
// Close callback example:
InventoryGui withClose = InventoryGui.builder(this, 9, "Menu")
.onClose(p -> p.sendPlainMessage("Menu closed"))
.build();Practical notes
- All clicks in the GUI view are cancelled, including clicks in the player's bottom inventory while the GUI is open. This keeps the menu read-only.
- The internal click listener is registered lazily on first
open(). It is automatically unregistered when the last viewer closes the GUI. You do not normally need to callclose(). onClose(Consumer<Player>)adds a callback that runs each time a player closes the GUI.setItem(int, ItemStack)updates a slot while the GUI is open.- Calling
close()manually is safe. It unregisters listeners and clears viewers; the GUI can be reopened later and the listener is re-registered. - Click handlers run on the main thread (inventory events are main-thread events). Do not block there.
- The handler receives the event after it is already cancelled. You can still read slot, click type, and player from it.
- Inventory size must be a positive multiple of 9. Slots outside the size are rejected at build time.
Registry
Registry<T> is a named, thread-safe map. SimpleRegistry<T> is the default implementation.
import org.coffeepop.betterPlugin.api.registry.Registry;
import org.coffeepop.betterPlugin.api.registry.SimpleRegistry;
// Standalone usage:
Registry<Storage> storages = new SimpleRegistry<>();
storages.register("main", new FileStorage());
storages.get("main").ifPresent(Storage::load);If you extend PluginBase, a shared registry is already available:
registry().register("storage", new FileStorage());
registry().get("storage").ifPresent(Storage::load);Modules
ModuleRegistry manages named modules with enable/disable lifecycle.
import org.coffeepop.betterPlugin.api.registry.ModuleRegistry;
import org.coffeepop.betterPlugin.api.registry.PluginModule;
public final class MyPlugin extends JavaPlugin {
private ModuleRegistry modules;
@Override
public void onEnable() {
modules = new ModuleRegistry(this);
modules.register("backup", new PluginModule() {
@Override
public void onEnable(JavaPlugin plugin) {
// start backup task
}
@Override
public void onDisable() {
// stop backup task
}
});
modules.enableAll();
}
@Override
public void onDisable() {
modules.disableAll();
}
}Practical notes
- If you extend
PluginBase, usemodules()instead of creating aModuleRegistryyourself. It is disabled automatically on plugin disable. - Registry operations are thread-safe. You can register and read from async tasks.
register(...)replaces an existing value with the same key. Use unique keys to avoid accidental overwrites.enable(...)is idempotent. Enabling an already-enabled module does not callonEnableagain.disable(...)is also idempotent. Disabling a module that is not enabled does nothing.unregister(...)disables the module first if it is enabled, then removes it.- Call
disableAll()on plugin disable so every module can clean up its own tasks/listeners.
Common pitfalls checklist
- [ ] Cancel schedulers on disable (
cancelAll()). - [ ] Unregister event listeners on disable (
unregisterAll()). - [ ] Disable all modules on disable (
disableAll()). - [ ] Use main-thread tasks for Bukkit API that must run on the main thread.
- [ ] Re-read config values after
reload(). - [ ] Don't keep GUI references after the last viewer closes unless you reopen it.
- [ ] Give registry keys unique, stable names.
- [ ] Build static items once and reuse them.