BukkitOfUtils — Developer Guide
BOU provides a framework for building Bukkit plugins with built-in Folia support, command building, configuration management, GUI menus, Modrinth update checks, and async task scheduling.
Example Project: GitHub — ExampleProject
Adding the Dependency
Gradle
repositories {
mavenCentral()
mavenLocal()
maven {
url = uri("https://repo.codemc.io/repository/streamline-essentials/")
}
}
dependencies {
compileOnly("gg.drak:BukkitOfUtils:1.19.4")
}Maven
<repositories>
<repository>
<id>streamline-essentials</id>
<url>https://repo.codemc.io/repository/streamline-essentials/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>gg.drak</groupId>
<artifactId>BukkitOfUtils</artifactId>
<version>1.19.4</version>
<scope>provided</scope>
</dependency>
</dependencies>Don't forget to add depend: [BukkitOfUtils] to your plugin.yml.
BOU targets Java 11. The published artifact is the shaded plugin jar and its POM declares no dependencies:
- TheBase is included. Its classes ship inside BOU under
gg.drak.thebase(with its libraries relocated undergg.drak.thebase.lib.*). Do not add a separategg.drak:TheBasedependency. - The inventory API is included. BOU provides the
mc.obliviate.inventoryAPI (Gui,Icon,GuiIcon,InventoryAPI) itself, so existing imports keep compiling. Do not depend on OblivateInvs. GUI titles are plainStrings, and 1.8 servers reject titles longer than 32 characters. - Runtime libraries are not transitive. HikariCP, Gson, Caffeine, SQLite/MySQL drivers, and the HTTP clients are provided at runtime (see Runtime Libraries), but if your code references their classes directly, declare them as
compileOnly/providedyourself.
BetterPlugin
All plugins using BOU should extend BetterPlugin instead of JavaPlugin. It provides lifecycle hooks and thread-safe utilities.
public class MyPlugin extends BetterPlugin {
public static final String MODRINTH_ID = "your-modrinth-project-id";
@Override
public String getModrinthId() {
return MODRINTH_ID; // used by /boup info, menus, and the version checker
}
@Override
public void onBaseConstruct() {
// Called during construction
}
@Override
public void onBaseLoad() {
// Called when the plugin loads (like onLoad)
}
@Override
public void onBaseEnabling() {
// Called at the start of enabling, before base initialization
}
@Override
public void onBaseEnabled() {
// Called after the plugin is fully enabled
setPluginMenuBuilder((player, plugin) -> {
// Optional: custom GUI opened from /boup menu <plugin>
return new MyPluginMenu(player, plugin);
});
}
@Override
public void onBaseDisable() {
// Called when the plugin is disabled — cleanup here
}
}Utility Methods
| Method | Description |
|---|---|
sync(Runnable) | Run a task on the main thread immediately |
sync(Runnable, delay) | Run a task on the main thread after a delay (ticks) |
sync(Runnable, delay, period) | Run a repeating task on the main thread |
isSync() | Check if the current thread is the main server thread |
registerListener(Listener) | Register an event listener |
unregisterListener(Listener) | Unregister an event listener |
getFactory("key") | Get a registered RetrievableItem by key |
getItem("key") | Get an ItemStack from a registered factory |
getModrinthId() | Modrinth project id/slug for update checks (override to enable) |
getFormattedUptime() | Human-readable uptime since last enable (Disabled when off) |
setPluginMenuBuilder(BetterPluginMenuBuilder) | Register a custom screen for /boup menu <plugin> |
getAsInfoComponent() | Formatted info string used by /boup info |
Plugin Menu Builder
BetterPluginMenuBuilder builds a ScreenInstance opened from the BOU plugin info menu:
@FunctionalInterface
public interface BetterPluginMenuBuilder {
ScreenInstance build(Player player, BetterPlugin plugin);
}If you do not set a builder, the info menu shows that no custom menu was provided.
HelpfulPlugin
Implement HelpfulPlugin to appear in /bouhelp (GUI for players, text for console). Provide getHelpfulInfo() and getHelpful(); BOU handles sending the document or opening the help GUI.
Command Builder
BOU provides a fluent CommandBuilder API for registering commands without plugin.yml entries.
new CommandBuilder("greet", this)
.setDescription("Greets the player")
.setUsage("/greet <name>")
.setBasePermission("myplugin.greet")
.addAliases("hello", "hi")
.setExecutionHandler(ctx -> {
if (!ctx.isPlayer()) {
ctx.sendMessage("&cOnly players can use this.");
return CommandResult.FAILURE;
}
String name = ctx.isArgUsable(0)
? ctx.getStringArg(0)
: ctx.getPlayerOrNull().getName();
ctx.sendMessage("&aHello, " + name + "!");
return CommandResult.SUCCESS;
})
.setTabCompleter(ctx -> {
// Tab completions are a ConcurrentSkipListSet<String>
return PlayerUtils.getOnlinePlayerNames();
})
.build();CommandContext
The CommandContext object passed to execution handlers provides:
| Method | Description |
|---|---|
isPlayer() / isConsole() | Check sender type |
getPlayer() | Optional<Player> — sender as player |
getPlayerOrNull() | Player or null |
getUuid() | UUID of the sender as a string |
sendMessage(String) | Send a color-coded message |
sendMessage(BaseComponent...) | Send component messages |
sendTitle(title, subtitle, fadeIn, stay, fadeOut) | Send a title screen |
getArg(int) | Get a CommandArgument by index |
getStringArg(int) | Get an argument as a string |
getIntArg(int) / getDoubleArg(int) / getBooleanArg(int) | Parse an argument (Optional) |
isArgUsable(int) | Whether an argument exists at that index |
getArgCount() | Number of arguments |
getArgsAsString() | All arguments joined as one string |
getFullCommand() | The full command string |
CommandResult
| Value | Returns |
|---|---|
CommandResult.SUCCESS | true |
CommandResult.FAILURE | false |
CommandResult.ERROR | false |
Folia-Compatible Scheduling
Folia uses regionalized threading — each chunk region has its own thread. Any code that interacts with location-bound objects (entities, blocks, worlds) must run on that object's region thread. BOU's TaskManager handles this automatically.
Region-specific objects include: Entities (Players, Mobs), Worlds, Locations, Blocks, and anything with a location.
TaskManager
// Immediate execution on main thread
TaskManager.runTask(() -> { /* code */ });
// Delayed execution (in ticks)
TaskManager.runTaskLater(() -> { /* code */ }, 20L); // 1 second
// Repeating task
TaskManager.runTaskTimer(() -> { /* code */ }, 0L, 20L); // Every second
// Entity-bound (routes to entity's region thread for Folia)
TaskManager.runTask(entity, () -> { /* code */ });
TaskManager.runTaskLater(entity, () -> { /* code */ }, 20L);
TaskManager.runTaskTimer(entity, () -> { /* code */ }, 0L, 20L);
// Region/Chunk-bound
TaskManager.runTask(world, chunkX, chunkZ, () -> { /* code */ });
TaskManager.runTask(chunk, () -> { /* code */ });
// Entity teleportation (Folia-safe)
TaskManager.teleport(entity, location);
// The runTask* methods return null if BOU's task system is not running.
// Thread checking
TaskManager.isThreadSync(); // On primary thread?
TaskManager.isThreadSync(entity); // On entity's region thread?
TaskManager.isThreadSync(location); // On location's region thread?CompletableTask
TaskManager.schedule() returns a CompletableTask for more control:
CompletableTask task = TaskManager.schedule(() -> { /* code */ });
CompletableTask task = TaskManager.schedule(() -> { /* code */ }, delay);
CompletableTask task = TaskManager.schedule(() -> { /* code */ }, delay, period);
task.whenComplete(() -> { /* runs after the task has run */ });
task.cancel();A whenComplete callback added after the task has already run is called right away; a callback on a cancelled task never runs. Entity, chunk, and teleport overloads mirror the runTask* methods above.
Task Ownership
Tasks scheduled through TaskManager, CompletableTask, and BaseRunnable belong to the plugin whose class provides the runnable (lambdas and method references belong to the plugin that wrote them). When that plugin is disabled, BOU cancels its BaseRunnables and pending CompletableTasks, so you do not need to track them yourself for cleanup.
Example: Damaging Entities Safely
// Pass true if already on the main thread, false if async.
public static void damageAllEntities(boolean isCurrentlySync, double amount) {
if (isCurrentlySync) {
damageAllEntitiesInSync(amount);
} else {
TaskManager.runTask(() -> {
damageAllEntitiesInSync(amount);
});
}
}
// Must be called from the main thread.
public static void damageAllEntitiesInSync(double amount) {
EntityUtils.collectEntitiesThenDo(entity -> {
// Routes to the entity's region thread for Folia support.
TaskManager.runTask(entity, () -> {
entity.damage(amount);
});
});
}Async Task Timers
BOU provides BaseRunnable for repeating and delayed async timers.
WARNING
Task timers run asynchronously. When accessing entities, locations, or worlds inside a timer, use TaskManager to switch to the correct thread.
public class BasicRepeatingTimer extends BaseRunnable {
public BasicRepeatingTimer() {
super(0, 20); // delay: 0 ticks, period: 20 ticks (1 second)
}
@Override
public void run() {
// Runs every second, asynchronously.
DamageHandler.damageAllEntities(false, 1d);
}
}Constructing a BaseRunnable registers and starts it, so do not call TaskManager.start on it again. A run that takes longer than the period delays the next run instead of overlapping it. For a one-shot delayed task, extend BaseDelayedRunnable (fires exactly once).
BasicRepeatingTimer timer = new BasicRepeatingTimer(); // Starts immediately
timer.pause(); // Pause / resume
timer.resume();
TaskManager.cancel(timer); // Stop and unregisterSimple Configurations
Config helpers come from TheBase (gg.drak.thebase.storage.resources.flat.simple.SimpleConfiguration), which is bundled inside BOU. They give easy config file management, and reads pick up edits made to the file on disk (checked at most every 5 seconds).
import gg.drak.thebase.storage.resources.flat.simple.SimpleConfiguration;
public class ExampleConfig extends SimpleConfiguration {
public ExampleConfig(BetterPlugin bouPlugin) {
super(
"config.yml", // File name (created automatically)
bouPlugin, // Your plugin instance
false // true: copy the default file from your jar's resources; false: start empty
);
}
@Override
public void init() {
}
public String getMyLovelyString() {
return getOrSetDefault("my.lovely.string", "BOU is really great!");
}
}TIP
get(...), getOrSetDefault(...), and getResource() re-read the file only if it changed on disk, checking at most every 5 seconds, so they are safe to call as often as you want (even every tick). No explicit reloadResource() call is needed before reading.
Utility Classes
MessageUtils
// Send colored messages
MessageUtils.sendMessage(sender, "&aColored message");
MessageUtils.doReplaceAndSend(sender, "Line 1%newline%Line 2", "&7Prefix: ");
// Logging (respects plugin log-level config)
MessageUtils.logInfo("message", plugin);
MessageUtils.logWarning("message", plugin);
MessageUtils.logSevere("message", plugin);
MessageUtils.logDebug("message", plugin);
// String utilities
String coded = MessageUtils.codedString(input); // Apply color codes
String newlined = MessageUtils.newLined(text); // Replace %newline% with \n
String truncated = MessageUtils.truncateString(s, 50); // Truncate to lengthColorUtils
// Colorize with & codes
String colored = ColorUtils.colorize("&aGreen &cRed text");
// Colorize with hex support (&#RRGGBB format)
String hex = ColorUtils.colorizeHard("&#FF5733Orange text");
// Create chat components
BaseComponent[] components = ColorUtils.color(message);
BaseComponent[] clickable = ColorUtils.colorWithClickable(message, clickEvent);
BaseComponent[] hoverable = ColorUtils.colorWithHoverable(message, hoverEvent);
// Strip all formatting
String clean = ColorUtils.stripFormatting("&aColored §btext");ItemUtils
// Create items
ItemStack item = ItemUtils.make(Material.DIAMOND, "&bShiny Diamond", "&7A precious gem");
ItemStack item = ItemUtils.make("DIAMOND", "&bShiny Diamond", "Line 1", "Line 2");
// With player context (processes placeholders)
ItemStack item = ItemUtils.make(player, Material.DIAMOND, "&bDiamond", true, "Lore");
// NBT serialization
String nbt = ItemUtils.getItemNBT(itemStack);
Optional<ItemStack> fromNbt = ItemUtils.getItem(nbtJsonString);
// Persistent data tags
ItemUtils.setTag(itemStack, plugin, "key", "value");
Optional<String> value = ItemUtils.getTag(itemStack, plugin, "key");
// Comparison
boolean equal = ItemUtils.isItemEqual(item1, item2);
boolean empty = ItemUtils.isNothingItem(itemStack);EntityUtils
// Iterate entities (thread-safe)
EntityUtils.collectEntitiesThenDo(entity -> { /* per entity */ });
EntityUtils.collectLivingEntitiesThenDo(living -> { /* per living entity */ });
EntityUtils.collectEntitiesInWorldThenDoSet("world", entities -> { /* collection */ });
// Entity counts
int total = EntityUtils.totalEntities();
int inWorld = EntityUtils.totalEntities(world);
// Damage tracking
Optional<Player> damager = EntityUtils.getLastDamager(entity);LocationUtils
// Parse and serialize
Optional<Location> loc = LocationUtils.fromString("world,100.5,64,200.5");
Optional<Location> loc = LocationUtils.fromString("world,100.5,64,200.5,90,0", true); // with yaw/pitch
String str = LocationUtils.toString(location);
// Location helpers
Location centered = LocationUtils.getCenteredLocation(location); // Center X/Z, resets yaw/pitch
Location centerH = LocationUtils.getCenteredHorizontally(location); // Center X/Z, keeps facing
Location center3d = LocationUtils.getCenteredFully(location); // Center X/Y/Z, keeps facing
Location top = LocationUtils.getTopLocation(location); // Nearest safe spot (searches down, then up)
Location topMost = LocationUtils.getTopMostTopLocation(location); // Top-most safe spot at XZ
boolean safe = LocationUtils.checkForTopableBlock(location); // Is it safe to stand?
// Teleportation (Folia-safe)
LocationUtils.teleport(entity, location);
// Item dropping
LocationUtils.dropItems(location, itemMap);
LocationUtils.dropItemsWithDirection(location, itemMap, direction);PlayerUtils
// Online player data
ConcurrentSkipListSet<String> names = PlayerUtils.getOnlinePlayerNames();
ConcurrentSkipListSet<String> uuids = PlayerUtils.getOnlinePlayerUUIDs();
// Offline player data
ConcurrentSkipListSet<String> allNames = PlayerUtils.getOfflinePlayerNames();
Stream<OfflinePlayer> all = PlayerUtils.getOfflinePlayersStream();StringUtils
// Capitalization
String result = StringUtils.capitalize(input, CapitalizationType.UPPER_FIRST);
// Types: LOWER_ALL, LOWER_FIRST, UPPER_ALL, UPPER_FIRST,
// WORD_LOWER_ALL, WORD_LOWER_FIRST, WORD_UPPER_ALL, WORD_UPPER_FIRST
// Truncation
String truncated = StringUtils.truncate(input, maxLength);
String decimals = StringUtils.truncateDecimals("3.14159", 2); // "3.14"Database Operations
BOU includes DBOperator for database access (MySQL or SQLite) with HikariCP connection pooling.
public class MyDatabase extends DBOperator {
public MyDatabase(ConnectorSet connectorSet, BetterPlugin plugin) {
super(connectorSet, plugin);
}
@Override
public void ensureDatabase() { /* CREATE DATABASE IF NOT EXISTS */ }
@Override
public void ensureTables() { /* CREATE TABLE IF NOT EXISTS */ }
}Usage
// Connect
ConnectorSet connector = new ConnectorSet(
DatabaseType.MYSQL, // or DatabaseType.SQLITE
"localhost", // host
3306, // port
"mydb", // database
"username",
"password",
"myplugin_", // table prefix
"database.db" // SQLite file name (used for SQLITE)
);
MyDatabase db = new MyDatabase(connector, this);
db.ensureUsable(); // builds the pool, then runs ensureDatabase / ensureTables / alters
// Execute statements (supports multiple separated by ;)
// Statement builders and result handlers are plain Consumers, so catch SQLException inside.
List<ExecutionResult> results = db.execute(
"INSERT INTO players (name) VALUES (?)",
stmt -> {
try {
stmt.setString(1, "Steve");
} catch (SQLException e) {
e.printStackTrace();
}
}
);
// Execute queries
db.executeQuery(
"SELECT * FROM players WHERE name = ?",
stmt -> {
try {
stmt.setString(1, "Steve");
} catch (SQLException e) {
e.printStackTrace();
}
},
resultSet -> {
try {
while (resultSet.next()) {
String name = resultSet.getString("name");
}
} catch (SQLException e) {
e.printStackTrace();
}
}
);
// Shutdown when done
db.shutdown();- SQLite files are stored in your plugin's
storage/folder. connector.prefixTable("players")applies the configured table prefix.- After
shutdown(), the pool is not rebuilt implicitly; callensureUsable()orbuildDataSource()to reopen it.shutdown()waits (up to 5 seconds) only while connections are still in use. - For MySQL,
autoReconnect=trueis added to the JDBC URL, andallowPublicKeyRetrieval=trueonly for loopback hosts; the driver's default SSL mode applies. Extra driver options can be appended to the database name (for examplemydb?sslMode=DISABLED).
Events
PluginDisableEvent
Subscribe to plugin disable events:
// Any plugin disabling
BetterPlugin.subscribeDisable(event -> {
// Handle any BetterPlugin disable
});
// Only your plugin
myPlugin.subscribeDisableIfSame(event -> {
// Handle only myPlugin disable
});Modrinth Version Checking
BOU can check Modrinth for updates via the Drak API. For BOU itself this is controlled in base-config.yml (version-checker.*). Dependent plugins opt in by overriding getModrinthId():
@Override
public String getModrinthId() {
return "your-modrinth-project-id-or-slug";
}Once set, /boup info <plugin> and /boup menu <plugin> show Modrinth status (up to date / behind / ahead) and can provide a download link when outdated.
Runtime Libraries
Large Maven Central dependencies (HikariCP, SQLite/MySQL drivers, Gson, Caffeine, HTTP clients, etc.) are declared in BOU's plugin.yml libraries: block so modern Spigot/Paper can download them. On older servers that ignore libraries:, BOU's RuntimeLibraryLoader downloads the same artifacts at runtime into plugins/BukkitOfUtils/libraries/, loading each jar only if its SHA-256 matches a pinned hash. Dependent plugins do not need to shade those libraries when relying on BOU, but they are not exposed as Maven dependencies, so declare any you compile against as compileOnly / provided.